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::boxed::Box;
19use alloc::format;
20use alloc::string::{String, ToString};
21use alloc::vec::Vec;
22
23use spg_sql::ast::{BinOp, CastTarget, ColumnName, Expr, Literal, UnOp};
24use spg_storage::{ColumnSchema, DataType, Row, TsLexeme, TsQueryAst, Value};
25
26/// Resolution context for evaluating a single row. `table_alias` is the alias
27/// (or table name) callers should accept as the qualifier on a column ref —
28/// e.g. `FROM users AS u` makes `u.name` valid and rejects `other.name`.
29#[derive(Clone)]
30#[allow(missing_debug_implementations)] // sequence_resolver is a dyn Fn — no Debug
31pub struct EvalContext<'a> {
32    pub columns: &'a [ColumnSchema],
33    pub table_alias: Option<&'a str>,
34    /// v6.1.1 — bound parameters for `$N` placeholders inside the
35    /// expression tree. Empty for simple queries; populated by the
36    /// prepared-statement Execute path with Bind values converted
37    /// to `Value`. Index N (1-based per PG) hits `params[N-1]`.
38    pub params: &'a [Value],
39    /// v7.12.1 — session text-search config (from `SET
40    /// default_text_search_config = '<name>'`). Resolved when the
41    /// engine builds an `EvalContext` and consumed by the FTS
42    /// function dispatcher when `to_tsvector(text)` /
43    /// `plainto_tsquery(text)` etc are called without an explicit
44    /// config arg. `None` falls through to `simple`.
45    pub default_text_search_config: Option<&'a str>,
46    /// v7.17.0 Phase 1.1 — `nextval` / `currval` / `setval`
47    /// resolver. The engine builds this around a `&mut Catalog`
48    /// so apply_function can mutate sequence state without
49    /// eval owning a catalog reference. When `None`, sequence
50    /// functions return an error (read-only contexts).
51    pub sequence_resolver: Option<&'a SequenceResolver<'a>>,
52}
53
54/// v7.17.0 — sequence-mutating callback used by `apply_function`
55/// for `nextval` / `currval` / `setval`. Implemented by the
56/// engine to thread `&mut Catalog` access through an immutable
57/// `&EvalContext`.
58pub type SequenceResolver<'a> = dyn Fn(SequenceOp) -> Result<i64, EvalError> + 'a;
59
60/// v7.17.0 — sequence operation requested by an Expr eval.
61#[derive(Debug, Clone)]
62pub enum SequenceOp {
63    Next(String),
64    Curr(String),
65    Set {
66        name: String,
67        value: i64,
68        is_called: bool,
69    },
70}
71
72impl<'a> EvalContext<'a> {
73    pub const fn new(columns: &'a [ColumnSchema], table_alias: Option<&'a str>) -> Self {
74        Self {
75            columns,
76            table_alias,
77            params: &[],
78            default_text_search_config: None,
79            sequence_resolver: None,
80        }
81    }
82
83    /// v7.17.0 — attach a sequence resolver. The engine wraps a
84    /// `&mut Catalog` in a closure that performs the requested
85    /// SequenceOp.
86    #[must_use]
87    pub const fn with_sequence_resolver(mut self, resolver: &'a SequenceResolver<'a>) -> Self {
88        self.sequence_resolver = Some(resolver);
89        self
90    }
91
92    /// v6.1.1 — attach a parameter buffer for `$N` placeholder
93    /// resolution. The slice must outlive the context; callers
94    /// construct it from the prepared statement's Bind values.
95    #[must_use]
96    pub const fn with_params(mut self, params: &'a [Value]) -> Self {
97        self.params = params;
98        self
99    }
100
101    /// v7.12.1 — attach the session's
102    /// `default_text_search_config`. Used by the FTS function
103    /// dispatcher when no explicit config arg is given.
104    #[must_use]
105    pub const fn with_default_text_search_config(mut self, cfg: Option<&'a str>) -> Self {
106        self.default_text_search_config = cfg;
107        self
108    }
109}
110
111#[derive(Debug, Clone, PartialEq)]
112pub enum EvalError {
113    ColumnNotFound {
114        name: String,
115    },
116    UnknownQualifier {
117        qualifier: String,
118    },
119    DivisionByZero,
120    TypeMismatch {
121        detail: String,
122    },
123    /// v6.1.1 — `$N` reference past the number of bound parameters.
124    /// Either the client sent too few in Bind, or the SQL has a
125    /// placeholder the prepared statement didn't account for.
126    PlaceholderOutOfRange {
127        n: u16,
128        bound: u16,
129    },
130}
131
132impl core::fmt::Display for EvalError {
133    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134        match self {
135            Self::ColumnNotFound { name } => write!(f, "column not found: {name}"),
136            Self::UnknownQualifier { qualifier } => {
137                write!(f, "unknown table qualifier: {qualifier}")
138            }
139            Self::DivisionByZero => f.write_str("division by zero"),
140            Self::TypeMismatch { detail } => write!(f, "type mismatch: {detail}"),
141            Self::PlaceholderOutOfRange { n, bound } => write!(
142                f,
143                "parameter ${n} referenced but only {bound} bound by client"
144            ),
145        }
146    }
147}
148
149pub fn eval_expr(expr: &Expr, row: &Row, ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
150    match expr {
151        Expr::Literal(l) => Ok(literal_to_value(l)),
152        Expr::Column(c) => resolve_column(c, row, ctx),
153        Expr::Placeholder(n) => {
154            let idx = usize::from(*n).saturating_sub(1);
155            ctx.params
156                .get(idx)
157                .cloned()
158                .ok_or_else(|| EvalError::PlaceholderOutOfRange {
159                    n: *n,
160                    bound: u16::try_from(ctx.params.len()).unwrap_or(u16::MAX),
161                })
162        }
163        Expr::Unary { op, expr } => {
164            let v = eval_expr(expr, row, ctx)?;
165            apply_unary(*op, v)
166        }
167        Expr::Binary { lhs, op, rhs } => {
168            let l = eval_expr(lhs, row, ctx)?;
169            let r = eval_expr(rhs, row, ctx)?;
170            // v7.17.0 Phase 2.5 — collation-aware text comparison.
171            // When either operand of a comparison op references a
172            // column declared `COLLATE "case_insensitive"` (or any
173            // MySQL `_ci` collation), case-fold both sides before
174            // the byte-wise compare so `WHERE name = 'foo'` matches
175            // stored `'Foo'`. Non-Text values fall straight through
176            // — the helper is a no-op outside Text-Text equality
177            // and inequality.
178            let (l, r) = collation_fold_for_compare(*op, lhs, rhs, l, r, ctx);
179            apply_binary(*op, l, r)
180        }
181        Expr::Cast { expr, target } => {
182            let v = eval_expr(expr, row, ctx)?;
183            cast_value(v, *target)
184        }
185        Expr::IsNull { expr, negated } => {
186            let v = eval_expr(expr, row, ctx)?;
187            let is_null = matches!(v, Value::Null);
188            Ok(Value::Bool(if *negated { !is_null } else { is_null }))
189        }
190        Expr::FunctionCall { name, args } => {
191            let evaluated: Result<Vec<Value>, _> =
192                args.iter().map(|a| eval_expr(a, row, ctx)).collect();
193            apply_function(name, &evaluated?, ctx)
194        }
195        Expr::Like {
196            expr,
197            pattern,
198            negated,
199        } => {
200            let v = eval_expr(expr, row, ctx)?;
201            let p = eval_expr(pattern, row, ctx)?;
202            // NULL on either side propagates to NULL — same as PG.
203            let (text, pat) = match (v, p) {
204                (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
205                (Value::Text(a), Value::Text(b)) => (a, b),
206                (Value::Text(_), other) | (other, _) => {
207                    return Err(EvalError::TypeMismatch {
208                        detail: format!("LIKE requires text operands, got {:?}", other.data_type()),
209                    });
210                }
211            };
212            let m = like_match(&text, &pat);
213            Ok(Value::Bool(if *negated { !m } else { m }))
214        }
215        Expr::Extract { field, source } => {
216            let v = eval_expr(source, row, ctx)?;
217            extract_field(*field, &v)
218        }
219        // v4.10: subquery nodes should have been resolved into
220        // Literal / Binary-Eq-OR chains by Engine::resolve_select_subqueries
221        // before the row loop. Anything reaching here is a bug.
222        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {
223            Err(EvalError::TypeMismatch {
224                detail: "subquery reached row eval — engine resolver bug".into(),
225            })
226        }
227        // v4.12: window functions should have been rewritten into
228        // synthetic __win_N column references by
229        // exec_select_with_window before row eval. Anything
230        // reaching here is similarly a bug.
231        Expr::WindowFunction { .. } => Err(EvalError::TypeMismatch {
232            detail: "window function reached row eval — engine rewrite bug".into(),
233        }),
234        // v7.10.10 — `ARRAY[expr, expr, …]` constructor.
235        // v7.11.13 — element-type detection: all integers →
236        // IntArray (or BigIntArray when widening), any Text →
237        // TextArray. Non-TEXT non-integer elements (Bool, Float)
238        // stringify into TextArray as the safe default.
239        Expr::Array(items) => {
240            let mut materialised: Vec<Value> = Vec::with_capacity(items.len());
241            for elem in items {
242                materialised.push(eval_expr(elem, row, ctx)?);
243            }
244            let mut has_text = false;
245            let mut has_bigint = false;
246            let mut has_int = false;
247            for v in &materialised {
248                match v {
249                    Value::Null => {}
250                    Value::Int(_) | Value::SmallInt(_) => has_int = true,
251                    Value::BigInt(_) => has_bigint = true,
252                    Value::Text(_) | Value::Json(_) => has_text = true,
253                    _ => has_text = true,
254                }
255            }
256            if has_text || (!has_int && !has_bigint) {
257                let out: Vec<Option<String>> = materialised
258                    .into_iter()
259                    .map(|v| match v {
260                        Value::Null => None,
261                        Value::Text(s) | Value::Json(s) => Some(s),
262                        other => Some(value_to_text_for_array(&other)),
263                    })
264                    .collect();
265                return Ok(Value::TextArray(out));
266            }
267            if has_bigint {
268                let out: Vec<Option<i64>> = materialised
269                    .into_iter()
270                    .map(|v| match v {
271                        Value::Null => None,
272                        Value::Int(n) => Some(i64::from(n)),
273                        Value::SmallInt(n) => Some(i64::from(n)),
274                        Value::BigInt(n) => Some(n),
275                        _ => unreachable!(),
276                    })
277                    .collect();
278                return Ok(Value::BigIntArray(out));
279            }
280            let out: Vec<Option<i32>> = materialised
281                .into_iter()
282                .map(|v| match v {
283                    Value::Null => None,
284                    Value::Int(n) => Some(n),
285                    Value::SmallInt(n) => Some(i32::from(n)),
286                    _ => unreachable!(),
287                })
288                .collect();
289            Ok(Value::IntArray(out))
290        }
291        // v7.10.12 — `arr[i]` PG-style 1-based indexing.
292        // Out-of-range indices (including i ≤ 0) return NULL.
293        Expr::ArraySubscript { target, index } => {
294            let target_v = eval_expr(target, row, ctx)?;
295            let idx_v = eval_expr(index, row, ctx)?;
296            if matches!(target_v, Value::Null) || matches!(idx_v, Value::Null) {
297                return Ok(Value::Null);
298            }
299            let i: i64 = match idx_v {
300                Value::Int(n) => i64::from(n),
301                Value::BigInt(n) => n,
302                Value::SmallInt(n) => i64::from(n),
303                other => {
304                    return Err(EvalError::TypeMismatch {
305                        detail: format!(
306                            "array subscript must be integer, got {:?}",
307                            other.data_type()
308                        ),
309                    });
310                }
311            };
312            if i < 1 {
313                return Ok(Value::Null);
314            }
315            let pos = (i - 1) as usize;
316            match target_v {
317                Value::TextArray(items) => match items.get(pos) {
318                    Some(Some(s)) => Ok(Value::Text(s.clone())),
319                    Some(None) | None => Ok(Value::Null),
320                },
321                Value::IntArray(items) => match items.get(pos) {
322                    Some(Some(n)) => Ok(Value::Int(*n)),
323                    Some(None) | None => Ok(Value::Null),
324                },
325                Value::BigIntArray(items) => match items.get(pos) {
326                    Some(Some(n)) => Ok(Value::BigInt(*n)),
327                    Some(None) | None => Ok(Value::Null),
328                },
329                other => Err(EvalError::TypeMismatch {
330                    detail: format!(
331                        "subscript target must be an array, got {:?}",
332                        other.data_type()
333                    ),
334                }),
335            }
336        }
337        // v7.10.12 — `x op ANY(arr)` / `x op ALL(arr)`. PG
338        // 3VL: ANY → true if any element compares-true; NULL if
339        // no true but some NULL; false otherwise. ALL: false if
340        // any compares-false; NULL if no false but some NULL;
341        // true otherwise.
342        Expr::AnyAll {
343            expr,
344            op,
345            array,
346            is_any,
347        } => {
348            let lhs = eval_expr(expr, row, ctx)?;
349            let arr = eval_expr(array, row, ctx)?;
350            if matches!(arr, Value::Null) {
351                return Ok(Value::Null);
352            }
353            let elems: Vec<Option<Value>> = match arr {
354                Value::TextArray(items) => items.into_iter().map(|o| o.map(Value::Text)).collect(),
355                Value::IntArray(items) => items.into_iter().map(|o| o.map(Value::Int)).collect(),
356                Value::BigIntArray(items) => {
357                    items.into_iter().map(|o| o.map(Value::BigInt)).collect()
358                }
359                other => {
360                    return Err(EvalError::TypeMismatch {
361                        detail: format!(
362                            "ANY/ALL right-hand side must be an array, got {:?}",
363                            other.data_type()
364                        ),
365                    });
366                }
367            };
368            let mut saw_null = matches!(lhs, Value::Null);
369            let mut saw_match = false;
370            let mut saw_mismatch = false;
371            for elem in elems {
372                let elem_v = match elem {
373                    Some(v) => v,
374                    None => {
375                        saw_null = true;
376                        continue;
377                    }
378                };
379                if matches!(lhs, Value::Null) {
380                    saw_null = true;
381                    continue;
382                }
383                match apply_binary(*op, lhs.clone(), elem_v) {
384                    Ok(Value::Bool(true)) => saw_match = true,
385                    Ok(Value::Bool(false)) => saw_mismatch = true,
386                    Ok(Value::Null) => saw_null = true,
387                    Ok(other) => {
388                        return Err(EvalError::TypeMismatch {
389                            detail: format!(
390                                "ANY/ALL comparison didn't return Bool: {:?}",
391                                other.data_type()
392                            ),
393                        });
394                    }
395                    Err(e) => return Err(e),
396                }
397            }
398            let result = if *is_any {
399                if saw_match {
400                    Value::Bool(true)
401                } else if saw_null {
402                    Value::Null
403                } else {
404                    Value::Bool(false)
405                }
406            } else if saw_mismatch {
407                Value::Bool(false)
408            } else if saw_null {
409                Value::Null
410            } else {
411                Value::Bool(true)
412            };
413            Ok(result)
414        }
415        // v7.13.0 — CASE WHEN … END (mailrs round-5 G9).
416        // Short-circuit on the first matching branch. Searched form
417        // (operand=None) treats each branch's WHEN as a Bool
418        // predicate. Simple form (operand=Some) compares with =.
419        // ELSE on no match; NULL if no ELSE.
420        Expr::Case {
421            operand,
422            branches,
423            else_branch,
424        } => {
425            let operand_value = match operand {
426                Some(o) => Some(eval_expr(o, row, ctx)?),
427                None => None,
428            };
429            for (when_expr, then_expr) in branches {
430                let when_value = eval_expr(when_expr, row, ctx)?;
431                let matched = match &operand_value {
432                    None => matches!(when_value, Value::Bool(true)),
433                    Some(op_v) => matches!(
434                        apply_binary(spg_sql::ast::BinOp::Eq, op_v.clone(), when_value)?,
435                        Value::Bool(true)
436                    ),
437                };
438                if matched {
439                    return eval_expr(then_expr, row, ctx);
440                }
441            }
442            match else_branch {
443                Some(e) => eval_expr(e, row, ctx),
444                None => Ok(Value::Null),
445            }
446        }
447    }
448}
449
450/// v7.10.10 — best-effort text rendering for non-TEXT array
451/// elements (numbers, bools, etc.). The PG rule is that
452/// `ARRAY[1, 2]` is `int[]`, but SPG's v7.10 only models TEXT[],
453/// so we widen by stringifying. NUMERIC formatting goes through
454/// the existing canonical helpers to stay consistent with
455/// `format_numeric` / `format_date` etc.
456fn value_to_text_for_array(v: &Value) -> String {
457    match v {
458        Value::Text(s) | Value::Json(s) => s.clone(),
459        Value::Int(n) => n.to_string(),
460        Value::BigInt(n) => n.to_string(),
461        Value::SmallInt(n) => n.to_string(),
462        Value::Bool(b) => {
463            if *b {
464                "true".into()
465            } else {
466                "false".into()
467            }
468        }
469        Value::Float(x) => format!("{x}"),
470        Value::Date(d) => format_date(*d),
471        Value::Timestamp(t) => format_timestamp(*t),
472        Value::Numeric { scaled, scale } => format_numeric(*scaled, *scale),
473        _ => format!("{v:?}"),
474    }
475}
476
477/// Pull an integer component (year / month / ... / microsecond) out
478/// of a `DATE` or `TIMESTAMP`. Returns NULL on a NULL source, errors
479/// when the source isn't a calendar type.
480fn extract_field(field: spg_sql::ast::ExtractField, v: &Value) -> Result<Value, EvalError> {
481    use spg_sql::ast::ExtractField as F;
482    if matches!(v, Value::Null) {
483        return Ok(Value::Null);
484    }
485    // INTERVAL has its own decomposition — `YEAR` / `MONTH` come from
486    // the months part, the rest from the microseconds part. PG matches
487    // this convention (months is normalised modulo 12 for MONTH).
488    if let Value::Interval { months, micros } = *v {
489        let years = months / 12;
490        let mons = months % 12;
491        let secs_total = micros / 1_000_000;
492        let frac = micros % 1_000_000;
493        let result = match field {
494            F::Year => i64::from(years),
495            F::Month => i64::from(mons),
496            F::Day => micros / 86_400_000_000,
497            F::Hour => (secs_total / 3600) % 24,
498            F::Minute => (secs_total / 60) % 60,
499            F::Second => secs_total % 60,
500            F::Microsecond => (secs_total % 60) * 1_000_000 + frac,
501            // total seconds in the interval (months count as 30 days,
502            // PG's justify_interval convention).
503            F::Epoch => i64::from(months) * 30 * 86_400 + secs_total,
504        };
505        return Ok(Value::BigInt(result));
506    }
507    let (days, day_micros) = match *v {
508        Value::Date(d) => (d, 0_i64),
509        Value::Timestamp(t) => {
510            let days = t.div_euclid(86_400_000_000);
511            let day_micros = t.rem_euclid(86_400_000_000);
512            (i32::try_from(days).unwrap_or(i32::MAX), day_micros)
513        }
514        _ => {
515            return Err(EvalError::TypeMismatch {
516                detail: format!(
517                    "EXTRACT requires DATE / TIMESTAMP / INTERVAL, got {:?}",
518                    v.data_type()
519                ),
520            });
521        }
522    };
523    let (y, m, d) = civil_components(days);
524    let secs = day_micros / 1_000_000;
525    let hh = secs / 3600;
526    let mm = (secs / 60) % 60;
527    let ss = secs % 60;
528    let frac = day_micros % 1_000_000;
529    let result = match field {
530        F::Year => i64::from(y),
531        F::Month => i64::from(m),
532        F::Day => i64::from(d),
533        F::Hour => hh,
534        F::Minute => mm,
535        F::Second => ss,
536        F::Microsecond => ss * 1_000_000 + frac,
537        // seconds since the unix epoch (truncated; PG returns
538        // numeric with fraction — mailrs casts ::BIGINT anyway).
539        F::Epoch => i64::from(days) * 86_400 + secs,
540    };
541    Ok(Value::BigInt(result))
542}
543
544/// Internal wrapper around the file-private `civil_from_days` so the
545/// public surface area doesn't change. Returns `(year, month, day)`.
546fn civil_components(days: i32) -> (i32, u32, u32) {
547    civil_from_days(days)
548}
549
550/// SQL `LIKE` matcher. Wildcards are `%` (any run, possibly empty) and `_`
551/// (exactly one char). `\` escapes the next pattern char so `\%` matches a
552/// literal `%`. Matches the whole input — no implicit anchoring needed
553/// since SQL `LIKE` is always full-string.
554fn like_match(text: &str, pattern: &str) -> bool {
555    let text: Vec<char> = text.chars().collect();
556    let pat: Vec<char> = pattern.chars().collect();
557    like_match_inner(&text, 0, &pat, 0)
558}
559
560fn like_match_inner(text: &[char], mut ti: usize, pat: &[char], mut pi: usize) -> bool {
561    while pi < pat.len() {
562        match pat[pi] {
563            '%' => {
564                // Collapse consecutive `%` and try every possible split.
565                while pi < pat.len() && pat[pi] == '%' {
566                    pi += 1;
567                }
568                if pi == pat.len() {
569                    return true;
570                }
571                for k in ti..=text.len() {
572                    if like_match_inner(text, k, pat, pi) {
573                        return true;
574                    }
575                }
576                return false;
577            }
578            '_' => {
579                if ti >= text.len() {
580                    return false;
581                }
582                ti += 1;
583                pi += 1;
584            }
585            '\\' if pi + 1 < pat.len() => {
586                let want = pat[pi + 1];
587                if ti >= text.len() || text[ti] != want {
588                    return false;
589                }
590                ti += 1;
591                pi += 2;
592            }
593            c => {
594                if ti >= text.len() || text[ti] != c {
595                    return false;
596                }
597                ti += 1;
598                pi += 1;
599            }
600        }
601    }
602    ti == text.len()
603}
604
605/// Dispatch on lowercased function name. v1.4 implements only a handful of
606/// scalar functions; aggregates land in v1.5 alongside GROUP BY.
607fn apply_function(name: &str, args: &[Value], ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
608    match name.to_ascii_lowercase().as_str() {
609        // v7.17.0 Phase 1.1 — SEQUENCE accessor functions.
610        "nextval" => {
611            if args.len() != 1 {
612                return Err(EvalError::TypeMismatch {
613                    detail: format!("nextval() takes 1 arg, got {}", args.len()),
614                });
615            }
616            let seq_name = match &args[0] {
617                Value::Text(s) => s.clone(),
618                Value::Null => return Ok(Value::Null),
619                other => {
620                    return Err(EvalError::TypeMismatch {
621                        detail: format!(
622                            "nextval() argument must be TEXT, got {:?}",
623                            other.data_type()
624                        ),
625                    });
626                }
627            };
628            let resolver = ctx
629                .sequence_resolver
630                .ok_or_else(|| EvalError::TypeMismatch {
631                    detail: "nextval() requires a sequence resolver (read-only context)".into(),
632                })?;
633            let v = resolver(SequenceOp::Next(seq_name))?;
634            Ok(Value::BigInt(v))
635        }
636        "currval" => {
637            if args.len() != 1 {
638                return Err(EvalError::TypeMismatch {
639                    detail: format!("currval() takes 1 arg, got {}", args.len()),
640                });
641            }
642            let seq_name = match &args[0] {
643                Value::Text(s) => s.clone(),
644                Value::Null => return Ok(Value::Null),
645                other => {
646                    return Err(EvalError::TypeMismatch {
647                        detail: format!(
648                            "currval() argument must be TEXT, got {:?}",
649                            other.data_type()
650                        ),
651                    });
652                }
653            };
654            let resolver = ctx
655                .sequence_resolver
656                .ok_or_else(|| EvalError::TypeMismatch {
657                    detail: "currval() requires a sequence resolver (read-only context)".into(),
658                })?;
659            let v = resolver(SequenceOp::Curr(seq_name))?;
660            Ok(Value::BigInt(v))
661        }
662        "setval" => {
663            if args.len() != 2 && args.len() != 3 {
664                return Err(EvalError::TypeMismatch {
665                    detail: format!("setval() takes 2 or 3 args, got {}", args.len()),
666                });
667            }
668            let seq_name = match &args[0] {
669                Value::Text(s) => s.clone(),
670                Value::Null => return Ok(Value::Null),
671                other => {
672                    return Err(EvalError::TypeMismatch {
673                        detail: format!(
674                            "setval() name argument must be TEXT, got {:?}",
675                            other.data_type()
676                        ),
677                    });
678                }
679            };
680            let value = match &args[1] {
681                Value::SmallInt(n) => i64::from(*n),
682                Value::Int(n) => i64::from(*n),
683                Value::BigInt(n) => *n,
684                Value::Null => return Ok(Value::Null),
685                other => {
686                    return Err(EvalError::TypeMismatch {
687                        detail: format!(
688                            "setval() value argument must be integer, got {:?}",
689                            other.data_type()
690                        ),
691                    });
692                }
693            };
694            let is_called = if args.len() == 3 {
695                match &args[2] {
696                    Value::Bool(b) => *b,
697                    Value::Null => return Ok(Value::Null),
698                    other => {
699                        return Err(EvalError::TypeMismatch {
700                            detail: format!(
701                                "setval() is_called argument must be BOOL, got {:?}",
702                                other.data_type()
703                            ),
704                        });
705                    }
706                }
707            } else {
708                true
709            };
710            let resolver = ctx
711                .sequence_resolver
712                .ok_or_else(|| EvalError::TypeMismatch {
713                    detail: "setval() requires a sequence resolver (read-only context)".into(),
714                })?;
715            let v = resolver(SequenceOp::Set {
716                name: seq_name,
717                value,
718                is_called,
719            })?;
720            Ok(Value::BigInt(v))
721        }
722        "length" => {
723            if args.len() != 1 {
724                return Err(EvalError::TypeMismatch {
725                    detail: format!("length() takes 1 arg, got {}", args.len()),
726                });
727            }
728            match &args[0] {
729                Value::Null => Ok(Value::Null),
730                Value::Text(s) => {
731                    let n = i32::try_from(s.chars().count()).unwrap_or(i32::MAX);
732                    Ok(Value::Int(n))
733                }
734                // v7.10.4 — PG semantics: length(bytea) returns
735                // byte count (= octet_length). Without this branch
736                // mailrs's INSERT … SELECT length(body) … against a
737                // BYTEA column would type-mismatch.
738                Value::Bytes(b) => {
739                    let n = i32::try_from(b.len()).unwrap_or(i32::MAX);
740                    Ok(Value::Int(n))
741                }
742                other => Err(EvalError::TypeMismatch {
743                    detail: format!("length() needs text or bytea, got {:?}", other.data_type()),
744                }),
745            }
746        }
747        // v7.10.4 — `OCTET_LENGTH(x)` returns byte count for both
748        // TEXT (UTF-8 byte length) and BYTEA. PG-spec name; aliases
749        // to length() for bytea by design.
750        "octet_length" => {
751            if args.len() != 1 {
752                return Err(EvalError::TypeMismatch {
753                    detail: format!("octet_length() takes 1 arg, got {}", args.len()),
754                });
755            }
756            match &args[0] {
757                Value::Null => Ok(Value::Null),
758                Value::Text(s) => {
759                    let n = i32::try_from(s.len()).unwrap_or(i32::MAX);
760                    Ok(Value::Int(n))
761                }
762                Value::Bytes(b) => {
763                    let n = i32::try_from(b.len()).unwrap_or(i32::MAX);
764                    Ok(Value::Int(n))
765                }
766                other => Err(EvalError::TypeMismatch {
767                    detail: format!(
768                        "octet_length() needs text or bytea, got {:?}",
769                        other.data_type()
770                    ),
771                }),
772            }
773        }
774        // v7.11.6 — `array_length(arr, dim)` returns the element
775        // count of `arr` along dimension `dim`. v7.11 only models
776        // single-dimension arrays so dim must be 1 (otherwise NULL,
777        // matching PG semantics for unsupported dimensions). NULL
778        // array → NULL. v7.11 TEXT[] only; non-array operand is
779        // a type mismatch.
780        "array_length" => {
781            if args.len() != 2 {
782                return Err(EvalError::TypeMismatch {
783                    detail: format!("array_length() takes 2 args, got {}", args.len()),
784                });
785            }
786            if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
787                return Ok(Value::Null);
788            }
789            let len = match &args[0] {
790                Value::TextArray(items) => items.len(),
791                Value::IntArray(items) => items.len(),
792                Value::BigIntArray(items) => items.len(),
793                _ => {
794                    return Err(EvalError::TypeMismatch {
795                        detail: format!(
796                            "array_length() first arg must be an array, got {:?}",
797                            args[0].data_type()
798                        ),
799                    });
800                }
801            };
802            let dim: i64 = match args[1] {
803                Value::Int(n) => i64::from(n),
804                Value::BigInt(n) => n,
805                Value::SmallInt(n) => i64::from(n),
806                _ => {
807                    return Err(EvalError::TypeMismatch {
808                        detail: format!(
809                            "array_length() second arg must be integer, got {:?}",
810                            args[1].data_type()
811                        ),
812                    });
813                }
814            };
815            if dim != 1 {
816                return Ok(Value::Null);
817            }
818            let n = i32::try_from(len).unwrap_or(i32::MAX);
819            Ok(Value::Int(n))
820        }
821        // v7.11.6 — `array_position(arr, val)` returns 1-based
822        // index of the first element of `arr` equal to `val`, or
823        // NULL if not found. PG NULL semantics: NULL array → NULL;
824        // NULL val never matches (returns NULL if absent).
825        "array_position" => {
826            if args.len() != 2 {
827                return Err(EvalError::TypeMismatch {
828                    detail: format!("array_position() takes 2 args, got {}", args.len()),
829                });
830            }
831            if matches!(args[0], Value::Null) {
832                return Ok(Value::Null);
833            }
834            if matches!(args[1], Value::Null) {
835                return Ok(Value::Null);
836            }
837            match (&args[0], &args[1]) {
838                (Value::TextArray(items), Value::Text(needle)) => {
839                    for (idx, item) in items.iter().enumerate() {
840                        if let Some(s) = item
841                            && s == needle
842                        {
843                            return Ok(Value::Int(i32::try_from(idx + 1).unwrap_or(i32::MAX)));
844                        }
845                    }
846                    Ok(Value::Null)
847                }
848                (Value::IntArray(items), needle_v)
849                    if matches!(
850                        needle_v,
851                        Value::Int(_) | Value::SmallInt(_) | Value::BigInt(_)
852                    ) =>
853                {
854                    let needle: i64 = match *needle_v {
855                        Value::Int(n) => i64::from(n),
856                        Value::SmallInt(n) => i64::from(n),
857                        Value::BigInt(n) => n,
858                        _ => unreachable!(),
859                    };
860                    for (idx, item) in items.iter().enumerate() {
861                        if let Some(n) = item
862                            && i64::from(*n) == needle
863                        {
864                            return Ok(Value::Int(i32::try_from(idx + 1).unwrap_or(i32::MAX)));
865                        }
866                    }
867                    Ok(Value::Null)
868                }
869                (Value::BigIntArray(items), needle_v)
870                    if matches!(
871                        needle_v,
872                        Value::Int(_) | Value::SmallInt(_) | Value::BigInt(_)
873                    ) =>
874                {
875                    let needle: i64 = match *needle_v {
876                        Value::Int(n) => i64::from(n),
877                        Value::SmallInt(n) => i64::from(n),
878                        Value::BigInt(n) => n,
879                        _ => unreachable!(),
880                    };
881                    for (idx, item) in items.iter().enumerate() {
882                        if let Some(n) = item
883                            && *n == needle
884                        {
885                            return Ok(Value::Int(i32::try_from(idx + 1).unwrap_or(i32::MAX)));
886                        }
887                    }
888                    Ok(Value::Null)
889                }
890                (
891                    arr @ (Value::TextArray(_) | Value::IntArray(_) | Value::BigIntArray(_)),
892                    other,
893                ) => Err(EvalError::TypeMismatch {
894                    detail: format!(
895                        "array_position() needle type {:?} doesn't match array {:?}",
896                        other.data_type(),
897                        arr.data_type()
898                    ),
899                }),
900                (other, _) => Err(EvalError::TypeMismatch {
901                    detail: format!(
902                        "array_position() first arg must be an array, got {:?}",
903                        other.data_type()
904                    ),
905                }),
906            }
907        }
908        // v7.11.15 — `substring(s, start)` / `substring(s, start, length)`
909        // for both TEXT and BYTEA. PG semantics: `start` is 1-based;
910        // values ≤ 0 clamp into the string (i.e. effective start is
911        // adjusted so the window still begins at index 1 — but
912        // `length` is reduced by the clipped prefix). A NULL arg
913        // makes the result NULL. Out-of-range windows return an
914        // empty value, not NULL.
915        "substring" | "substr" => {
916            if !matches!(args.len(), 2 | 3) {
917                return Err(EvalError::TypeMismatch {
918                    detail: format!("substring() takes 2 or 3 args, got {}", args.len()),
919                });
920            }
921            if args.iter().any(|a| matches!(a, Value::Null)) {
922                return Ok(Value::Null);
923            }
924            let start: i64 = match args[1] {
925                Value::Int(n) => i64::from(n),
926                Value::BigInt(n) => n,
927                Value::SmallInt(n) => i64::from(n),
928                _ => {
929                    return Err(EvalError::TypeMismatch {
930                        detail: format!(
931                            "substring() start must be integer, got {:?}",
932                            args[1].data_type()
933                        ),
934                    });
935                }
936            };
937            let length: Option<i64> = if args.len() == 3 {
938                match args[2] {
939                    Value::Int(n) => Some(i64::from(n)),
940                    Value::BigInt(n) => Some(n),
941                    Value::SmallInt(n) => Some(i64::from(n)),
942                    _ => {
943                        return Err(EvalError::TypeMismatch {
944                            detail: format!(
945                                "substring() length must be integer, got {:?}",
946                                args[2].data_type()
947                            ),
948                        });
949                    }
950                }
951            } else {
952                None
953            };
954            // PG: when length is given, end = start + length; if
955            // end < start the result is empty. Clip start to 1.
956            let (effective_start, effective_length): (i64, Option<i64>) = match length {
957                Some(len) => {
958                    let end = start.saturating_add(len);
959                    if end <= 1 || len < 0 {
960                        return Ok(match &args[0] {
961                            Value::Text(_) => Value::Text(String::new()),
962                            Value::Bytes(_) => Value::Bytes(Vec::new()),
963                            other => {
964                                return Err(EvalError::TypeMismatch {
965                                    detail: format!(
966                                        "substring() needs text or bytea, got {:?}",
967                                        other.data_type()
968                                    ),
969                                });
970                            }
971                        });
972                    }
973                    let eff_start = start.max(1);
974                    let eff_len = end - eff_start;
975                    (eff_start, Some(eff_len.max(0)))
976                }
977                None => (start.max(1), None),
978            };
979            match &args[0] {
980                Value::Text(s) => {
981                    // PG counts in characters (codepoints) for TEXT.
982                    let chars: Vec<char> = s.chars().collect();
983                    let skip = (effective_start - 1) as usize;
984                    if skip >= chars.len() {
985                        return Ok(Value::Text(String::new()));
986                    }
987                    let take = match effective_length {
988                        Some(n) => (n as usize).min(chars.len() - skip),
989                        None => chars.len() - skip,
990                    };
991                    Ok(Value::Text(chars[skip..skip + take].iter().collect()))
992                }
993                Value::Bytes(b) => {
994                    let skip = (effective_start - 1) as usize;
995                    if skip >= b.len() {
996                        return Ok(Value::Bytes(Vec::new()));
997                    }
998                    let take = match effective_length {
999                        Some(n) => (n as usize).min(b.len() - skip),
1000                        None => b.len() - skip,
1001                    };
1002                    Ok(Value::Bytes(b[skip..skip + take].to_vec()))
1003                }
1004                other => Err(EvalError::TypeMismatch {
1005                    detail: format!(
1006                        "substring() needs text or bytea, got {:?}",
1007                        other.data_type()
1008                    ),
1009                }),
1010            }
1011        }
1012        // v7.11.15 — `position(needle, haystack)`. PG semantics:
1013        // 1-based byte/char index of first occurrence, or 0 if
1014        // absent. NULL on either operand → NULL. Empty needle
1015        // returns 1 (PG convention). Works on TEXT (char positions)
1016        // and BYTEA (byte positions). (The PG-spec syntax `position(
1017        // needle IN haystack)` is not parsed in v7.11; clients must
1018        // call the function-call form.)
1019        "position" => {
1020            if args.len() != 2 {
1021                return Err(EvalError::TypeMismatch {
1022                    detail: format!("position() takes 2 args, got {}", args.len()),
1023                });
1024            }
1025            if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
1026                return Ok(Value::Null);
1027            }
1028            match (&args[0], &args[1]) {
1029                (Value::Text(needle), Value::Text(haystack)) => {
1030                    if needle.is_empty() {
1031                        return Ok(Value::Int(1));
1032                    }
1033                    // Char-based position (PG uses character count).
1034                    let h_chars: Vec<char> = haystack.chars().collect();
1035                    let n_chars: Vec<char> = needle.chars().collect();
1036                    if n_chars.len() > h_chars.len() {
1037                        return Ok(Value::Int(0));
1038                    }
1039                    for i in 0..=h_chars.len() - n_chars.len() {
1040                        if h_chars[i..i + n_chars.len()] == n_chars[..] {
1041                            return Ok(Value::Int(i32::try_from(i + 1).unwrap_or(i32::MAX)));
1042                        }
1043                    }
1044                    Ok(Value::Int(0))
1045                }
1046                (Value::Bytes(needle), Value::Bytes(haystack)) => {
1047                    if needle.is_empty() {
1048                        return Ok(Value::Int(1));
1049                    }
1050                    if needle.len() > haystack.len() {
1051                        return Ok(Value::Int(0));
1052                    }
1053                    for i in 0..=haystack.len() - needle.len() {
1054                        if &haystack[i..i + needle.len()] == needle.as_slice() {
1055                            return Ok(Value::Int(i32::try_from(i + 1).unwrap_or(i32::MAX)));
1056                        }
1057                    }
1058                    Ok(Value::Int(0))
1059                }
1060                (a, b) => Err(EvalError::TypeMismatch {
1061                    detail: format!(
1062                        "position() operands must both be text or both bytea, got {:?} and {:?}",
1063                        a.data_type(),
1064                        b.data_type()
1065                    ),
1066                }),
1067            }
1068        }
1069        "upper" => {
1070            if args.len() != 1 {
1071                return Err(EvalError::TypeMismatch {
1072                    detail: format!("upper() takes 1 arg, got {}", args.len()),
1073                });
1074            }
1075            match &args[0] {
1076                Value::Null => Ok(Value::Null),
1077                Value::Text(s) => Ok(Value::Text(s.to_uppercase())),
1078                other => Err(EvalError::TypeMismatch {
1079                    detail: format!("upper() needs text, got {:?}", other.data_type()),
1080                }),
1081            }
1082        }
1083        "lower" => {
1084            if args.len() != 1 {
1085                return Err(EvalError::TypeMismatch {
1086                    detail: format!("lower() takes 1 arg, got {}", args.len()),
1087                });
1088            }
1089            match &args[0] {
1090                Value::Null => Ok(Value::Null),
1091                Value::Text(s) => Ok(Value::Text(s.to_lowercase())),
1092                other => Err(EvalError::TypeMismatch {
1093                    detail: format!("lower() needs text, got {:?}", other.data_type()),
1094                }),
1095            }
1096        }
1097        "abs" => {
1098            if args.len() != 1 {
1099                return Err(EvalError::TypeMismatch {
1100                    detail: format!("abs() takes 1 arg, got {}", args.len()),
1101                });
1102            }
1103            match &args[0] {
1104                Value::Null => Ok(Value::Null),
1105                Value::Int(n) => Ok(Value::Int(n.wrapping_abs())),
1106                Value::BigInt(n) => Ok(Value::BigInt(n.wrapping_abs())),
1107                Value::Float(x) => Ok(Value::Float(x.abs())),
1108                other => Err(EvalError::TypeMismatch {
1109                    detail: format!("abs() needs numeric, got {:?}", other.data_type()),
1110                }),
1111            }
1112        }
1113        "coalesce" => {
1114            for a in args {
1115                if !matches!(a, Value::Null) {
1116                    return Ok(a.clone());
1117                }
1118            }
1119            Ok(Value::Null)
1120        }
1121        "date_trunc" => date_trunc(args),
1122        "date_part" => date_part(args),
1123        "age" => age(args),
1124        "to_char" => to_char(args),
1125        // v7.17.0 Phase 3.P0-29 — MySQL time aliases. WordPress,
1126        // Laravel, mysql-connector-python emit these constantly.
1127        // `unix_timestamp()` (bare) is folded by clock_replacement_for
1128        // into a BigInt literal — this arm only handles the 1-arg
1129        // form (TIMESTAMP / DATE → epoch seconds).
1130        "date_format" => date_format_mysql(args),
1131        "unix_timestamp" => unix_timestamp_of(args),
1132        "from_unixtime" => from_unixtime(args),
1133        // v7.17.0 Phase 3.8 — PG `format(fmt, args…)` sprintf-style.
1134        // Conversion specifiers: `%s` (literal string from arg),
1135        // `%I` (quoted identifier), `%L` (quoted SQL literal),
1136        // `%%` (literal `%`). `%n$X` argument-position prefix
1137        // (1-based). NULL arg → empty string for %s; NULL for %I
1138        // is an error in PG; NULL for %L renders as the SQL
1139        // literal `NULL`. Args missing for a position → error.
1140        "format" => format_string(args),
1141        // PG `concat(args...)` — variadic; coerces every arg to
1142        // its text representation; NULL arguments are silently
1143        // skipped (the canonical PG semantic — `concat()` is the
1144        // NULL-tolerant counterpart to the `||` operator which
1145        // propagates NULL).
1146        //
1147        // Reference:
1148        //   https://www.postgresql.org/docs/current/functions-string.html
1149        //   "Concatenates the text representations of all the
1150        //   arguments. NULL arguments are ignored."
1151        //
1152        // Edge cases:
1153        //   * `concat()` (no args) → ''
1154        //   * Every arg NULL → '' (NEVER returns NULL — distinct
1155        //     from `||` and from `array_agg`)
1156        //   * Bool → PG single-char form 't' / 'f'
1157        //   * SmallInt / Int / BigInt / Float / Numeric / Date /
1158        //     Timestamp / Json / Bytes → their canonical text
1159        //     rendering (shared with `format()`'s %s specifier
1160        //     via `value_to_format_text`).
1161        "concat" => {
1162            let mut out = String::new();
1163            for v in args {
1164                if matches!(v, Value::Null) {
1165                    continue;
1166                }
1167                out.push_str(&value_to_format_text(v));
1168            }
1169            Ok(Value::Text(out))
1170        }
1171        // PG `concat_ws(sep, val1 [, val2 ...])` — like concat but
1172        // with a separator inserted between each pair of NON-NULL
1173        // arguments. Critical semantic subtleties:
1174        //   * NULL separator → NULL result (the sep position is
1175        //     mandatory and poison-prone; this is the ONLY way
1176        //     concat_ws can return NULL).
1177        //   * NULL data args silently SKIPPED — the separator is
1178        //     NOT inserted around them. `concat_ws(',', 'a', NULL,
1179        //     'b')` → `'a,b'`, not `'a,,b'`.
1180        //   * Empty-string data args are KEPT (separator placed
1181        //     around them). `concat_ws(',', 'a', '', 'b')` →
1182        //     `'a,,b'`. Distinction with NULL matters for code
1183        //     like `concat_ws(', ', first_name, middle_name,
1184        //     last_name)`.
1185        //   * 0 args → arity error (sep is mandatory).
1186        //   * Only sep (no data) → '' (NOT NULL — distinct from
1187        //     the all-NULL data case which also returns '').
1188        //
1189        // Reference:
1190        //   https://www.postgresql.org/docs/current/functions-string.html
1191        // PG `trim` / `ltrim` / `rtrim` / `btrim`.
1192        //
1193        // Semantic anchors (PG-canonical):
1194        //   * Default chars set is the ASCII SPACE only (NOT the
1195        //     POSIX whitespace class — tab / newline / form-feed
1196        //     stay put unless explicitly listed in `chars`).
1197        //   * `chars` arg is a UTF-8 codepoint SET — any char in
1198        //     the set is stripped, not the substring.
1199        //   * `trim(s)` == `btrim(s)` == strip both ends.
1200        //   * `ltrim(s, c)` / `rtrim(s, c)` strip only the named
1201        //     side; inner occurrences are preserved.
1202        //   * NULL on EITHER arg → NULL result.
1203        //   * Non-text input is coerced via `value_to_format_text`
1204        //     so trim(42) returns '42'.
1205        //
1206        // Reference:
1207        //   https://www.postgresql.org/docs/current/functions-string.html
1208        // PG `replace(string, from, to)` — substring substitution
1209        // for every (non-overlapping, greedy left-to-right)
1210        // occurrence. Empty `from` passes input through unchanged
1211        // (PG behavior — avoids infinite loop). Inserted text is
1212        // NOT re-scanned for new matches (so `replace('a', 'a',
1213        // 'aa')` terminates at `'aa'`, not blows up). NULL on any
1214        // arg poisons.
1215        // PG `split_part(string, delimiter, n)` — split on delim,
1216        // return the n-th field (1-indexed). Negative n counts
1217        // from the end (PG 14+). Out-of-range n → '' (NOT NULL).
1218        // n = 0 → error. Empty delimiter → error. NULL on any
1219        // arg → NULL.
1220        // PG `repeat(string, n)` — duplicate the input N times.
1221        // n=0 → ''; n<0 → '' (PG does NOT error on negative);
1222        // NULL on any arg → NULL.
1223        // PG `lpad(string, length [, fill])` / `rpad(...)`.
1224        // length is the target CODEPOINT count. Truncation when
1225        // input longer (lpad keeps the LEFT side, rpad keeps
1226        // LEFT too — both wait truncate from the right side per
1227        // PG-verified behavior). Padding when shorter, using
1228        // `fill` (default SPACE) cycling for multi-char fills.
1229        // length<=0 → ''. Empty fill + needs padding → returns
1230        // input verbatim (potentially truncated). NULL on any
1231        // arg → NULL.
1232        // PG `strpos(string, substring)` — same as position()
1233        // but with reversed arg order. PG convention is
1234        // strpos(haystack, needle); position(needle, haystack).
1235        // Both are 1-indexed; 0 = not found; codepoint-counted.
1236        // PG `left(string, n)` / `right(string, n)` — head/tail
1237        // substring helpers. Negative n means "all but last/first
1238        // |n| chars" — slice from the OPPOSITE side. n=0 → ''.
1239        // Codepoint-counted. NULL on any arg → NULL.
1240        // PG `floor(x)` — largest integer <= x.
1241        //   * Negative floats floor TOWARD -infinity, NOT toward 0.
1242        //   * Integer types passthrough unchanged.
1243        //   * NULL → NULL.
1244        // PG `ceil(x)` / `ceiling(x)` — smallest integer >= x.
1245        //   * Negative floats round TOWARD zero (toward +inf):
1246        //     ceil(-1.5) → -1, NOT -2.
1247        //   * Integer types passthrough unchanged.
1248        //   * NULL → NULL.
1249        // PG `round(x)` / `round(x, scale)` — half-away-from-zero
1250        // rounding (NUMERIC semantic).
1251        //   * round(0.5) → 1; round(-0.5) → -1; round(2.5) → 3.
1252        //   * Two-arg form rounds to N decimal places (n>0) or to
1253        //     nearest 10^|n| (n<0).
1254        //   * Integer types passthrough unchanged.
1255        //   * NULL on any arg → NULL.
1256        // PG `trunc(x)` / `trunc(x, scale)` — truncate TOWARD zero.
1257        //   * Distinct from floor() which rounds toward -inf:
1258        //     trunc(-1.7)→-1; floor(-1.7)→-2.
1259        //   * Distinct from round() which rounds half-away:
1260        //     trunc(1.5)→1; round(1.5)→2.
1261        //   * Two-arg form truncates to N decimal places (or 10^|n|
1262        //     for negative n).
1263        //   * Integer types passthrough unchanged.
1264        //   * NULL on any arg → NULL.
1265        // PG `nullif(a, b)` — returns NULL if a = b, else a.
1266        // Canonical use cases:
1267        //   * Divide-by-zero protection: `x / nullif(y, 0)`
1268        //   * Empty-string normalisation: `nullif(field, '')`
1269        // Edge: nullif(NULL, NULL) returns NULL. nullif(NULL, x)
1270        // returns NULL. nullif(x, NULL) returns x (since NULL is
1271        // not == to anything per IS DISTINCT FROM semantic, x ≠ NULL).
1272        // PG `greatest(...)` / `least(...)` — variadic max/min.
1273        // NULL args silently skipped (PG-canonical). All-NULL → NULL.
1274        // Cross-type widening for numeric comparisons.
1275        // PG `mod(y, x)` — modulo. Result sign follows dividend.
1276        //   * mod(7, 3) = 1
1277        //   * mod(-7, 3) = -1
1278        //   * mod(7, -3) = 1
1279        //   * mod(-7, -3) = -1
1280        // Division by zero → error. NULL on any arg → NULL.
1281        // PG `power(x, y)` / `pow(x, y)` — x^y.
1282        // Integer exponent → exact via repeated multiplication
1283        // (no precision loss). Fractional exponent → exp(y*ln(x))
1284        // via the no_std exp/ln series helpers.
1285        // x=0 with negative y → error (1/0). NULL → NULL.
1286        // PG `sqrt(x)` — square root. Negative input → error.
1287        // PG `sign(x)` — -1 / 0 / 1.
1288        // PG `random()` — uniform float in [0, 1). Per-row /
1289        // per-call: each evaluation returns a different value
1290        // even within the same statement. Backed by a xorshift64*
1291        // PRNG with a process-static seed; not cryptographically
1292        // secure (use a cryptographic source for security tokens).
1293        "random" => {
1294            if !args.is_empty() {
1295                return Err(EvalError::TypeMismatch {
1296                    detail: alloc::format!("random() takes 0 args, got {}", args.len()),
1297                });
1298            }
1299            Ok(Value::Float(prng_next_f64()))
1300        }
1301        // v7.17.0 — PG `gen_random_uuid()` (built-in, no extension)
1302        // and the historical uuid-ossp `uuid_generate_v4()` alias.
1303        // Both produce a RFC 4122 v4 (random) UUID. This is the
1304        // function Django / Rails / Hibernate emit in `id UUID
1305        // PRIMARY KEY DEFAULT gen_random_uuid()`, the modern
1306        // default PK pattern.
1307        "gen_random_uuid" | "uuid_generate_v4" => {
1308            if !args.is_empty() {
1309                return Err(EvalError::TypeMismatch {
1310                    detail: alloc::format!("{name}() takes 0 args, got {}", args.len()),
1311                });
1312            }
1313            Ok(Value::Uuid(gen_random_uuid_bytes()))
1314        }
1315        "sign" => {
1316            if args.len() != 1 {
1317                return Err(EvalError::TypeMismatch {
1318                    detail: alloc::format!("sign() takes 1 arg, got {}", args.len()),
1319                });
1320            }
1321            match &args[0] {
1322                Value::Null => Ok(Value::Null),
1323                Value::SmallInt(n) => Ok(Value::SmallInt(n.signum())),
1324                Value::Int(n) => Ok(Value::Int(n.signum())),
1325                Value::BigInt(n) => Ok(Value::BigInt(n.signum())),
1326                Value::Float(x) => {
1327                    let s = if *x > 0.0 {
1328                        1.0
1329                    } else if *x < 0.0 {
1330                        -1.0
1331                    } else {
1332                        0.0
1333                    };
1334                    Ok(Value::Float(s))
1335                }
1336                Value::Numeric { scaled, scale } => {
1337                    let s = scaled.signum();
1338                    Ok(Value::Numeric {
1339                        scaled: s * pow10_i128(*scale),
1340                        scale: *scale,
1341                    })
1342                }
1343                other => Err(EvalError::TypeMismatch {
1344                    detail: alloc::format!("sign() needs numeric, got {:?}", other.data_type()),
1345                }),
1346            }
1347        }
1348        "sqrt" => {
1349            if args.len() != 1 {
1350                return Err(EvalError::TypeMismatch {
1351                    detail: alloc::format!("sqrt() takes 1 arg, got {}", args.len()),
1352                });
1353            }
1354            match &args[0] {
1355                Value::Null => Ok(Value::Null),
1356                v => {
1357                    let x = value_to_f64(v).ok_or_else(|| EvalError::TypeMismatch {
1358                        detail: alloc::format!("sqrt() needs numeric, got {:?}", v.data_type()),
1359                    })?;
1360                    if x < 0.0 {
1361                        return Err(EvalError::TypeMismatch {
1362                            detail: "sqrt(): negative input outside real domain".into(),
1363                        });
1364                    }
1365                    if x == 0.0 {
1366                        return Ok(Value::Float(0.0));
1367                    }
1368                    Ok(Value::Float(f64_sqrt(x)))
1369                }
1370            }
1371        }
1372        "power" | "pow" => {
1373            if args.len() != 2 {
1374                return Err(EvalError::TypeMismatch {
1375                    detail: alloc::format!("power() takes 2 args, got {}", args.len()),
1376                });
1377            }
1378            if args.iter().any(|v| matches!(v, Value::Null)) {
1379                return Ok(Value::Null);
1380            }
1381            let x = value_to_f64(&args[0]).ok_or_else(|| EvalError::TypeMismatch {
1382                detail: "power() needs numeric x".into(),
1383            })?;
1384            let y = value_to_f64(&args[1]).ok_or_else(|| EvalError::TypeMismatch {
1385                detail: "power() needs numeric y".into(),
1386            })?;
1387            // Integer-exponent fast path.
1388            let y_int = y as i32;
1389            if (y_int as f64) == y && y.abs() < 1024.0 {
1390                let result = f64_powi(x, y_int);
1391                return Ok(Value::Float(result));
1392            }
1393            // Fractional exponent — only defined for x >= 0 in real
1394            // arithmetic. Negative x raised to fractional power is
1395            // complex; reject cleanly.
1396            if x < 0.0 {
1397                return Err(EvalError::TypeMismatch {
1398                    detail: "power(): negative base with fractional exponent yields complex result"
1399                        .into(),
1400                });
1401            }
1402            if x == 0.0 && y < 0.0 {
1403                return Err(EvalError::TypeMismatch {
1404                    detail: "power(): 0 raised to negative power is undefined".into(),
1405                });
1406            }
1407            if x == 0.0 {
1408                return Ok(Value::Float(0.0));
1409            }
1410            Ok(Value::Float(f64_exp(y * f64_ln(x))))
1411        }
1412        "mod" => {
1413            if args.len() != 2 {
1414                return Err(EvalError::TypeMismatch {
1415                    detail: alloc::format!("mod() takes 2 args, got {}", args.len()),
1416                });
1417            }
1418            if args.iter().any(|v| matches!(v, Value::Null)) {
1419                return Ok(Value::Null);
1420            }
1421            let to_i64 = |v: &Value| -> Result<i64, EvalError> {
1422                match v {
1423                    Value::SmallInt(x) => Ok(i64::from(*x)),
1424                    Value::Int(x) => Ok(i64::from(*x)),
1425                    Value::BigInt(x) => Ok(*x),
1426                    other => Err(EvalError::TypeMismatch {
1427                        detail: alloc::format!("mod() needs integer, got {:?}", other.data_type()),
1428                    }),
1429                }
1430            };
1431            let y = to_i64(&args[0])?;
1432            let x = to_i64(&args[1])?;
1433            if x == 0 {
1434                return Err(EvalError::TypeMismatch {
1435                    detail: "mod(): division by zero".into(),
1436                });
1437            }
1438            // Rust's `%` operator on signed integers follows the
1439            // dividend's sign — same as PG.
1440            let result = y % x;
1441            // Pick the narrowest type that holds the result.
1442            if let Ok(small) = i16::try_from(result) {
1443                if matches!(args[0], Value::SmallInt(_)) && matches!(args[1], Value::SmallInt(_)) {
1444                    return Ok(Value::SmallInt(small));
1445                }
1446            }
1447            if let Ok(int_) = i32::try_from(result) {
1448                if !matches!(args[0], Value::BigInt(_)) && !matches!(args[1], Value::BigInt(_)) {
1449                    return Ok(Value::Int(int_));
1450                }
1451            }
1452            Ok(Value::BigInt(result))
1453        }
1454        "greatest" | "least" => {
1455            if args.is_empty() {
1456                return Err(EvalError::TypeMismatch {
1457                    detail: alloc::format!(
1458                        "{lc}() takes at least 1 arg",
1459                        lc = if name.eq_ignore_ascii_case("greatest") {
1460                            "greatest"
1461                        } else {
1462                            "least"
1463                        }
1464                    ),
1465                });
1466            }
1467            let non_null: alloc::vec::Vec<&Value> =
1468                args.iter().filter(|v| !matches!(v, Value::Null)).collect();
1469            if non_null.is_empty() {
1470                return Ok(Value::Null);
1471            }
1472            let is_greatest = name.eq_ignore_ascii_case("greatest");
1473            let mut best = non_null[0].clone();
1474            for v in &non_null[1..] {
1475                let ord = value_cmp_for_min_max(&best, v);
1476                let take = if is_greatest {
1477                    ord == core::cmp::Ordering::Less
1478                } else {
1479                    ord == core::cmp::Ordering::Greater
1480                };
1481                if take {
1482                    best = (*v).clone();
1483                }
1484            }
1485            Ok(best)
1486        }
1487        // MySQL `ifnull(a, b)` — alias for coalesce(a, b).
1488        // Used by every ORM with a MySQL target (Hibernate /
1489        // Laravel / Sequelize).
1490        "ifnull" => {
1491            if args.len() != 2 {
1492                return Err(EvalError::TypeMismatch {
1493                    detail: alloc::format!("ifnull() takes 2 args, got {}", args.len()),
1494                });
1495            }
1496            for v in args {
1497                if !matches!(v, Value::Null) {
1498                    return Ok(v.clone());
1499                }
1500            }
1501            Ok(Value::Null)
1502        }
1503        // MySQL `if(cond, then, else)` — alias for CASE WHEN.
1504        // NULL condition → else branch (MySQL semantic).
1505        // Integer condition: nonzero is true.
1506        "if" => {
1507            if args.len() != 3 {
1508                return Err(EvalError::TypeMismatch {
1509                    detail: alloc::format!(
1510                        "if() takes 3 args (cond, then, else), got {}",
1511                        args.len()
1512                    ),
1513                });
1514            }
1515            let truthy = match &args[0] {
1516                Value::Null => false,
1517                Value::Bool(b) => *b,
1518                Value::SmallInt(n) => *n != 0,
1519                Value::Int(n) => *n != 0,
1520                Value::BigInt(n) => *n != 0,
1521                Value::Float(x) => *x != 0.0,
1522                Value::Text(s) => !s.is_empty() && s != "0",
1523                _ => true,
1524            };
1525            if truthy {
1526                Ok(args[1].clone())
1527            } else {
1528                Ok(args[2].clone())
1529            }
1530        }
1531        "nullif" => {
1532            if args.len() != 2 {
1533                return Err(EvalError::TypeMismatch {
1534                    detail: alloc::format!("nullif() takes 2 args, got {}", args.len()),
1535                });
1536            }
1537            match (&args[0], &args[1]) {
1538                (Value::Null, _) => Ok(Value::Null),
1539                (a, Value::Null) => Ok(a.clone()),
1540                (a, b) => {
1541                    // Use value_cmp (already defined as Ord-like
1542                    // function in lib.rs) — but it's not accessible
1543                    // here. Fall back to direct equality.
1544                    if values_equal_for_nullif(a, b) {
1545                        Ok(Value::Null)
1546                    } else {
1547                        Ok(a.clone())
1548                    }
1549                }
1550            }
1551        }
1552        "trunc" => {
1553            match args.len() {
1554                1 => match &args[0] {
1555                    Value::Null => Ok(Value::Null),
1556                    Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) => Ok(args[0].clone()),
1557                    Value::Float(x) => Ok(Value::Float(f64_trunc(*x))),
1558                    Value::Numeric { scaled, scale } => {
1559                        let factor = pow10_i128(*scale);
1560                        // Truncate toward zero — sign-preserving division.
1561                        let q = scaled / factor;
1562                        Ok(Value::Numeric {
1563                            scaled: q * factor,
1564                            scale: *scale,
1565                        })
1566                    }
1567                    other => Err(EvalError::TypeMismatch {
1568                        detail: alloc::format!(
1569                            "trunc() needs numeric, got {:?}",
1570                            other.data_type()
1571                        ),
1572                    }),
1573                },
1574                2 => {
1575                    if args.iter().any(|v| matches!(v, Value::Null)) {
1576                        return Ok(Value::Null);
1577                    }
1578                    let n = match &args[1] {
1579                        Value::SmallInt(x) => i32::from(*x),
1580                        Value::Int(x) => *x,
1581                        Value::BigInt(x) => {
1582                            i32::try_from(*x).map_err(|_| EvalError::TypeMismatch {
1583                                detail: "trunc(): scale must fit in i32".into(),
1584                            })?
1585                        }
1586                        other => {
1587                            return Err(EvalError::TypeMismatch {
1588                                detail: alloc::format!(
1589                                    "trunc(): scale must be integer, got {:?}",
1590                                    other.data_type()
1591                                ),
1592                            });
1593                        }
1594                    };
1595                    let x = match &args[0] {
1596                        Value::SmallInt(v) => f64::from(*v),
1597                        Value::Int(v) => f64::from(*v),
1598                        Value::BigInt(v) => *v as f64,
1599                        Value::Float(v) => *v,
1600                        Value::Numeric { scaled, scale } => {
1601                            (*scaled as f64) / f64_powi(10.0, i32::from(*scale))
1602                        }
1603                        other => {
1604                            return Err(EvalError::TypeMismatch {
1605                                detail: alloc::format!(
1606                                    "trunc() needs numeric x, got {:?}",
1607                                    other.data_type()
1608                                ),
1609                            });
1610                        }
1611                    };
1612                    let result = if n >= 0 {
1613                        let factor = f64_powi(10.0, n);
1614                        f64_trunc(x * factor) / factor
1615                    } else {
1616                        let factor = f64_powi(10.0, -n);
1617                        f64_trunc(x / factor) * factor
1618                    };
1619                    Ok(Value::Float(result))
1620                }
1621                _ => Err(EvalError::TypeMismatch {
1622                    detail: alloc::format!("trunc() takes 1 or 2 args, got {}", args.len()),
1623                }),
1624            }
1625        }
1626        "round" => {
1627            match args.len() {
1628                1 => match &args[0] {
1629                    Value::Null => Ok(Value::Null),
1630                    Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) => Ok(args[0].clone()),
1631                    Value::Float(x) => Ok(Value::Float(f64_round_half_away(*x))),
1632                    Value::Numeric { scaled, scale } => {
1633                        let factor = pow10_i128(*scale);
1634                        let q = scaled.div_euclid(factor);
1635                        let r = scaled.rem_euclid(factor);
1636                        // Half-away-from-zero: if 2*r >= factor → round up.
1637                        let result = if 2 * r >= factor { q + 1 } else { q };
1638                        Ok(Value::Numeric {
1639                            scaled: result * factor,
1640                            scale: *scale,
1641                        })
1642                    }
1643                    other => Err(EvalError::TypeMismatch {
1644                        detail: alloc::format!(
1645                            "round() needs numeric, got {:?}",
1646                            other.data_type()
1647                        ),
1648                    }),
1649                },
1650                2 => {
1651                    if args.iter().any(|v| matches!(v, Value::Null)) {
1652                        return Ok(Value::Null);
1653                    }
1654                    let n = match &args[1] {
1655                        Value::SmallInt(x) => i32::from(*x),
1656                        Value::Int(x) => *x,
1657                        Value::BigInt(x) => {
1658                            i32::try_from(*x).map_err(|_| EvalError::TypeMismatch {
1659                                detail: "round(): scale must fit in i32".into(),
1660                            })?
1661                        }
1662                        other => {
1663                            return Err(EvalError::TypeMismatch {
1664                                detail: alloc::format!(
1665                                    "round(): scale must be integer, got {:?}",
1666                                    other.data_type()
1667                                ),
1668                            });
1669                        }
1670                    };
1671                    // Convert input to f64 for arithmetic
1672                    // simplicity (PG does NUMERIC math here but
1673                    // SPG's f64 path matches the dominant
1674                    // customer expectation for round(N, scale)
1675                    // patterns).
1676                    let x = match &args[0] {
1677                        Value::SmallInt(v) => f64::from(*v),
1678                        Value::Int(v) => f64::from(*v),
1679                        Value::BigInt(v) => *v as f64,
1680                        Value::Float(v) => *v,
1681                        Value::Numeric { scaled, scale } => {
1682                            (*scaled as f64) / f64_powi(10.0, i32::from(*scale))
1683                        }
1684                        other => {
1685                            return Err(EvalError::TypeMismatch {
1686                                detail: alloc::format!(
1687                                    "round() needs numeric x, got {:?}",
1688                                    other.data_type()
1689                                ),
1690                            });
1691                        }
1692                    };
1693                    // Avoid float precision drift from the
1694                    // 10^(-k) reciprocal — for n<0 work with the
1695                    // positive-exponent form: round(x / 10^|n|) *
1696                    // 10^|n|.
1697                    let result = if n >= 0 {
1698                        let factor = f64_powi(10.0, n);
1699                        f64_round_half_away(x * factor) / factor
1700                    } else {
1701                        let factor = f64_powi(10.0, -n);
1702                        f64_round_half_away(x / factor) * factor
1703                    };
1704                    Ok(Value::Float(result))
1705                }
1706                _ => Err(EvalError::TypeMismatch {
1707                    detail: alloc::format!("round() takes 1 or 2 args, got {}", args.len()),
1708                }),
1709            }
1710        }
1711        "ceil" | "ceiling" => {
1712            if args.len() != 1 {
1713                return Err(EvalError::TypeMismatch {
1714                    detail: alloc::format!("ceil() takes 1 arg, got {}", args.len()),
1715                });
1716            }
1717            match &args[0] {
1718                Value::Null => Ok(Value::Null),
1719                Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) => Ok(args[0].clone()),
1720                Value::Float(x) => Ok(Value::Float(f64_ceil(*x))),
1721                Value::Numeric { scaled, scale } => {
1722                    let factor = pow10_i128(*scale);
1723                    let q = scaled.div_euclid(factor);
1724                    let r = scaled.rem_euclid(factor);
1725                    let result = if r == 0 { q } else { q + 1 };
1726                    Ok(Value::Numeric {
1727                        scaled: result * factor,
1728                        scale: *scale,
1729                    })
1730                }
1731                other => Err(EvalError::TypeMismatch {
1732                    detail: alloc::format!("ceil() needs numeric, got {:?}", other.data_type()),
1733                }),
1734            }
1735        }
1736        "floor" => {
1737            if args.len() != 1 {
1738                return Err(EvalError::TypeMismatch {
1739                    detail: alloc::format!("floor() takes 1 arg, got {}", args.len()),
1740                });
1741            }
1742            match &args[0] {
1743                Value::Null => Ok(Value::Null),
1744                Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) => Ok(args[0].clone()),
1745                Value::Float(x) => Ok(Value::Float(f64_floor(*x))),
1746                Value::Numeric { scaled, scale } => {
1747                    let factor = pow10_i128(*scale);
1748                    let q = scaled.div_euclid(factor);
1749                    // div_euclid rounds toward -infinity which is
1750                    // exactly the floor semantic — perfect for
1751                    // negative values.
1752                    Ok(Value::Numeric {
1753                        scaled: q * factor,
1754                        scale: *scale,
1755                    })
1756                }
1757                other => Err(EvalError::TypeMismatch {
1758                    detail: alloc::format!("floor() needs numeric, got {:?}", other.data_type()),
1759                }),
1760            }
1761        }
1762        "left" => string_left_right(args, true, "left"),
1763        "right" => string_left_right(args, false, "right"),
1764        "strpos" => {
1765            if args.len() != 2 {
1766                return Err(EvalError::TypeMismatch {
1767                    detail: alloc::format!(
1768                        "strpos() takes 2 args (haystack, needle), got {}",
1769                        args.len()
1770                    ),
1771                });
1772            }
1773            if args.iter().any(|v| matches!(v, Value::Null)) {
1774                return Ok(Value::Null);
1775            }
1776            let haystack = value_to_format_text(&args[0]);
1777            let needle = value_to_format_text(&args[1]);
1778            if needle.is_empty() {
1779                return Ok(Value::Int(1));
1780            }
1781            let h_chars: Vec<char> = haystack.chars().collect();
1782            let n_chars: Vec<char> = needle.chars().collect();
1783            if n_chars.len() > h_chars.len() {
1784                return Ok(Value::Int(0));
1785            }
1786            for i in 0..=h_chars.len() - n_chars.len() {
1787                if h_chars[i..i + n_chars.len()] == n_chars[..] {
1788                    return Ok(Value::Int(i32::try_from(i + 1).unwrap_or(i32::MAX)));
1789                }
1790            }
1791            Ok(Value::Int(0))
1792        }
1793        "lpad" => string_pad(args, true, "lpad"),
1794        "rpad" => string_pad(args, false, "rpad"),
1795        "repeat" => {
1796            if args.len() != 2 {
1797                return Err(EvalError::TypeMismatch {
1798                    detail: alloc::format!("repeat() takes 2 args, got {}", args.len()),
1799                });
1800            }
1801            if args.iter().any(|v| matches!(v, Value::Null)) {
1802                return Ok(Value::Null);
1803            }
1804            let s = value_to_format_text(&args[0]);
1805            let n = match &args[1] {
1806                Value::SmallInt(x) => i64::from(*x),
1807                Value::Int(x) => i64::from(*x),
1808                Value::BigInt(x) => *x,
1809                other => {
1810                    return Err(EvalError::TypeMismatch {
1811                        detail: alloc::format!(
1812                            "repeat(): n must be integer, got {:?}",
1813                            other.data_type()
1814                        ),
1815                    });
1816                }
1817            };
1818            if n <= 0 {
1819                return Ok(Value::Text(String::new()));
1820            }
1821            // Safety cap so a runaway argument doesn't allocate
1822            // terabytes. PG itself enforces a similar cap via
1823            // work_mem; SPG inherits a defensive 64MiB cap.
1824            const MAX_REPEAT_BYTES: usize = 64 * 1024 * 1024;
1825            let needed =
1826                s.len()
1827                    .checked_mul(n as usize)
1828                    .ok_or_else(|| EvalError::TypeMismatch {
1829                        detail: "repeat(): result size overflows usize".into(),
1830                    })?;
1831            if needed > MAX_REPEAT_BYTES {
1832                return Err(EvalError::TypeMismatch {
1833                    detail: alloc::format!(
1834                        "repeat(): result would exceed {MAX_REPEAT_BYTES} bytes"
1835                    ),
1836                });
1837            }
1838            Ok(Value::Text(s.repeat(n as usize)))
1839        }
1840        "split_part" => {
1841            if args.len() != 3 {
1842                return Err(EvalError::TypeMismatch {
1843                    detail: alloc::format!(
1844                        "split_part() takes 3 args (string, delim, n), got {}",
1845                        args.len()
1846                    ),
1847                });
1848            }
1849            if args.iter().any(|v| matches!(v, Value::Null)) {
1850                return Ok(Value::Null);
1851            }
1852            let s = value_to_format_text(&args[0]);
1853            let delim = value_to_format_text(&args[1]);
1854            if delim.is_empty() {
1855                return Err(EvalError::TypeMismatch {
1856                    detail: "split_part(): delimiter cannot be empty".into(),
1857                });
1858            }
1859            let n = match &args[2] {
1860                Value::SmallInt(x) => i64::from(*x),
1861                Value::Int(x) => i64::from(*x),
1862                Value::BigInt(x) => *x,
1863                other => {
1864                    return Err(EvalError::TypeMismatch {
1865                        detail: alloc::format!(
1866                            "split_part(): n must be integer, got {:?}",
1867                            other.data_type()
1868                        ),
1869                    });
1870                }
1871            };
1872            if n == 0 {
1873                return Err(EvalError::TypeMismatch {
1874                    detail: "split_part(): n must be nonzero (PG: 1-indexed)".into(),
1875                });
1876            }
1877            let parts: alloc::vec::Vec<&str> = s.split(&delim[..]).collect();
1878            let total = parts.len() as i64;
1879            let idx = if n > 0 {
1880                n - 1
1881            } else {
1882                // n=-1 → last (idx = total - 1)
1883                total + n
1884            };
1885            if idx < 0 || idx >= total {
1886                return Ok(Value::Text(String::new()));
1887            }
1888            Ok(Value::Text(parts[idx as usize].to_string()))
1889        }
1890        // PG `translate(s, from, to)` — char-by-char positional
1891        // mapping. Each codepoint in `from` is replaced by the
1892        // codepoint at the same index in `to`. When `from` is
1893        // longer than `to`, the extra `from` codepoints are
1894        // DELETED (not replaced). When `from` has duplicates,
1895        // the FIRST occurrence's mapping wins. NULL → NULL.
1896        "translate" => {
1897            if args.len() != 3 {
1898                return Err(EvalError::TypeMismatch {
1899                    detail: alloc::format!("translate() takes 3 args, got {}", args.len()),
1900                });
1901            }
1902            if args.iter().any(|v| matches!(v, Value::Null)) {
1903                return Ok(Value::Null);
1904            }
1905            let s = value_to_format_text(&args[0]);
1906            let from = value_to_format_text(&args[1]);
1907            let to = value_to_format_text(&args[2]);
1908            let from_chars: Vec<char> = from.chars().collect();
1909            let to_chars: Vec<char> = to.chars().collect();
1910            // Build the codepoint map. First occurrence wins.
1911            let mut map: alloc::collections::BTreeMap<char, Option<char>> =
1912                alloc::collections::BTreeMap::new();
1913            for (i, &fc) in from_chars.iter().enumerate() {
1914                if map.contains_key(&fc) {
1915                    continue;
1916                }
1917                let replacement = to_chars.get(i).copied();
1918                map.insert(fc, replacement);
1919            }
1920            let mut out = String::with_capacity(s.len());
1921            for c in s.chars() {
1922                match map.get(&c) {
1923                    Some(Some(r)) => out.push(*r),
1924                    Some(None) => {} // mapped to "deleted"
1925                    None => out.push(c),
1926                }
1927            }
1928            Ok(Value::Text(out))
1929        }
1930        "replace" => {
1931            if args.len() != 3 {
1932                return Err(EvalError::TypeMismatch {
1933                    detail: alloc::format!(
1934                        "replace() takes 3 args (string, from, to), got {}",
1935                        args.len()
1936                    ),
1937                });
1938            }
1939            if args.iter().any(|v| matches!(v, Value::Null)) {
1940                return Ok(Value::Null);
1941            }
1942            let s = value_to_format_text(&args[0]);
1943            let from = value_to_format_text(&args[1]);
1944            let to = value_to_format_text(&args[2]);
1945            if from.is_empty() {
1946                return Ok(Value::Text(s));
1947            }
1948            // std `String::replace` matches PG semantics exactly:
1949            // non-overlapping, left-to-right, no re-scan of
1950            // inserted text. Sealed test surface verifies the
1951            // edge cases independently.
1952            Ok(Value::Text(s.replace(&from[..], &to)))
1953        }
1954        "trim" | "btrim" => string_trim(args, TrimSide::Both, "trim"),
1955        "ltrim" => string_trim(args, TrimSide::Left, "ltrim"),
1956        "rtrim" => string_trim(args, TrimSide::Right, "rtrim"),
1957        "concat_ws" => {
1958            if args.is_empty() {
1959                return Err(EvalError::TypeMismatch {
1960                    detail: "concat_ws() requires at least 1 arg (the separator)".into(),
1961                });
1962            }
1963            // NULL separator poisons the result.
1964            let sep = match &args[0] {
1965                Value::Null => return Ok(Value::Null),
1966                v => value_to_format_text(v),
1967            };
1968            let mut out = String::new();
1969            let mut first = true;
1970            for v in &args[1..] {
1971                if matches!(v, Value::Null) {
1972                    continue;
1973                }
1974                if first {
1975                    first = false;
1976                } else {
1977                    out.push_str(&sep);
1978                }
1979                out.push_str(&value_to_format_text(v));
1980            }
1981            Ok(Value::Text(out))
1982        }
1983        // v7.17.0 Phase 3.7 — PG regex function family.
1984        "regexp_matches" => regexp_matches(args),
1985        "regexp_replace" => regexp_replace(args),
1986        "regexp_split_to_array" => regexp_split_to_array(args),
1987        // v7.17.0 Phase 3.P0-28 — PG JSON builder family.
1988        // to_json / to_jsonb coerce any value to JSON text (NULL
1989        // becomes the JSON literal 'null', not SQL NULL).
1990        "to_json" | "to_jsonb" => {
1991            if args.len() != 1 {
1992                return Err(EvalError::TypeMismatch {
1993                    detail: alloc::format!("to_json() takes 1 arg, got {}", args.len()),
1994                });
1995            }
1996            // Json input passes through verbatim — PG identity.
1997            if let Value::Json(s) = &args[0] {
1998                return Ok(Value::Json(s.clone()));
1999            }
2000            Ok(Value::Json(crate::json::value_to_json_text(&args[0])))
2001        }
2002        "json_build_object" | "jsonb_build_object" => crate::json::build_object(args),
2003        "json_build_array" | "jsonb_build_array" => crate::json::build_array(args),
2004        "jsonb_set" | "json_set" => crate::json::set(args),
2005        "jsonb_insert" | "json_insert" => crate::json::insert(args),
2006        // v7.17.0 Phase 3.9 — PG `jsonb_path_query` family.
2007        "jsonb_path_query" | "json_path_query" => {
2008            if args.len() != 2 {
2009                return Err(EvalError::TypeMismatch {
2010                    detail: alloc::format!("jsonb_path_query() takes 2 args, got {}", args.len()),
2011                });
2012            }
2013            crate::json::path_query(&args[0], &args[1])
2014        }
2015        "jsonb_path_query_first" | "json_path_query_first" => {
2016            if args.len() != 2 {
2017                return Err(EvalError::TypeMismatch {
2018                    detail: alloc::format!(
2019                        "jsonb_path_query_first() takes 2 args, got {}",
2020                        args.len()
2021                    ),
2022                });
2023            }
2024            crate::json::path_query_first(&args[0], &args[1])
2025        }
2026        "jsonb_path_query_array" | "json_path_query_array" => {
2027            if args.len() != 2 {
2028                return Err(EvalError::TypeMismatch {
2029                    detail: alloc::format!(
2030                        "jsonb_path_query_array() takes 2 args, got {}",
2031                        args.len()
2032                    ),
2033                });
2034            }
2035            crate::json::path_query_array(&args[0], &args[1])
2036        }
2037        // v7.17.0 Phase 7 — INET / CIDR network helpers.
2038        "host" => inet_host(args),
2039        "network" => inet_network(args),
2040        "masklen" => inet_masklen(args),
2041        // v6.4.3 — encode/decode + error_on_null SQL function bundle.
2042        "encode" => encode_text(args),
2043        "decode" => decode_text(args),
2044        "error_on_null" => error_on_null(args),
2045        // v7.12.1 — PG full-text search lexer / tsquery builders.
2046        // mailrs G-CRIT-3 acceptance path: `to_tsvector('english',
2047        // … || ' ' || … || …)` runs end-to-end against a tsvector
2048        // column with Porter stemming + standard english stopwords.
2049        "to_tsvector" => fts_to_tsvector(args, ctx),
2050        "plainto_tsquery" => fts_plainto_tsquery(args, ctx),
2051        "phraseto_tsquery" => fts_phraseto_tsquery(args, ctx),
2052        "websearch_to_tsquery" => fts_websearch_to_tsquery(args, ctx),
2053        "to_tsquery" => fts_to_tsquery(args, ctx),
2054        // v7.12.2 — ranking functions. mailrs's fallback search
2055        // query ORDERs BY ts_rank(search_vector, q) DESC.
2056        "ts_rank" => fts_ts_rank(args),
2057        "ts_rank_cd" => fts_ts_rank_cd(args),
2058        // v7.14.0 — PG dump preamble emits
2059        // `SELECT pg_catalog.set_config('search_path', '', false);`
2060        // and friends. SPG is single-schema; accept-as-no-op
2061        // returning either the new value or NULL.
2062        "set_config" => Ok(args.get(1).cloned().unwrap_or(Value::Null)),
2063        "current_setting" => Ok(Value::Text(String::new())),
2064        // PG `pg_catalog.*` discovery / cast helpers commonly
2065        // emitted by ORMs probing the server. Accept-as-no-op
2066        // with sensible defaults so the dump preamble doesn't
2067        // fail. `pg_get_serial_sequence` returns NULL (no
2068        // sequence — SPG has AUTO_INCREMENT instead).
2069        "pg_get_serial_sequence" | "pg_get_constraintdef" | "pg_get_indexdef" => Ok(Value::Null),
2070        "version" => Ok(Value::Text("PostgreSQL 16 (SPG-compat)".into())),
2071        // v7.17.0 Phase 3.P0-30 — session / introspection functions.
2072        // Engine-level dispatch so these compose inside expressions
2073        // (`WHERE schemaname = current_schema()`, `SELECT *,
2074        // database() AS db FROM t`) — the pgwire layer's canned
2075        // shortcuts only catch the bare top-level SELECT shape.
2076        // SPG is single-database + single-schema; the values
2077        // mirror the wire-layer canned defaults.
2078        "current_database" | "database" => Ok(Value::Text("spg".into())),
2079        "current_schema" => Ok(Value::Text("public".into())),
2080        "current_user" | "session_user" | "user" => Ok(Value::Text("admin".into())),
2081        // v7.17.0 Phase 3.P0-31 — `pg_typeof(any)` returns the
2082        // canonical PG lowercase type name. sqlx / SQLAlchemy /
2083        // Diesel emit this during describe; generic ORMs may
2084        // branch on it (`CASE WHEN pg_typeof(x) = 'jsonb' ...`).
2085        // NULL has no resolved value-level type → 'unknown' per
2086        // PG semantics.
2087        "pg_typeof" => {
2088            if args.len() != 1 {
2089                return Err(EvalError::TypeMismatch {
2090                    detail: format!("pg_typeof() takes 1 arg, got {}", args.len()),
2091                });
2092            }
2093            Ok(Value::Text(pg_typeof_name(&args[0]).into()))
2094        }
2095        // v7.17.0 — `nextval` / `currval` / `setval` are handled
2096        // at the top of this match against the SequenceResolver.
2097        // `lastval()` (no-arg session memory) still degrades to
2098        // NULL pending a Phase 1.1b session tracker.
2099        "lastval" => Ok(Value::Null),
2100        // v7.15.0 — pg_trgm: similarity, show_trgm. Match PG
2101        // semantics: similarity returns Jaccard of trigram sets;
2102        // show_trgm returns the trigram set as TEXT[]. NULL on
2103        // any NULL arg.
2104        "similarity" => {
2105            if args.len() != 2 {
2106                return Err(EvalError::TypeMismatch {
2107                    detail: format!("similarity() takes 2 args, got {}", args.len()),
2108                });
2109            }
2110            if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
2111                return Ok(Value::Null);
2112            }
2113            let a = match &args[0] {
2114                Value::Text(s) => s.as_str(),
2115                other => {
2116                    return Err(EvalError::TypeMismatch {
2117                        detail: format!("similarity() needs text, got {:?}", other.data_type()),
2118                    });
2119                }
2120            };
2121            let b = match &args[1] {
2122                Value::Text(s) => s.as_str(),
2123                other => {
2124                    return Err(EvalError::TypeMismatch {
2125                        detail: format!("similarity() needs text, got {:?}", other.data_type()),
2126                    });
2127                }
2128            };
2129            // PG returns REAL (f32) — we use Float (f64) and let
2130            // coerce_value narrow on assignment to a REAL column.
2131            Ok(Value::Float(spg_storage::trgm::similarity(a, b)))
2132        }
2133        "show_trgm" => {
2134            if args.len() != 1 {
2135                return Err(EvalError::TypeMismatch {
2136                    detail: format!("show_trgm() takes 1 arg, got {}", args.len()),
2137                });
2138            }
2139            if matches!(args[0], Value::Null) {
2140                return Ok(Value::Null);
2141            }
2142            let s = match &args[0] {
2143                Value::Text(s) => s.as_str(),
2144                other => {
2145                    return Err(EvalError::TypeMismatch {
2146                        detail: format!("show_trgm() needs text, got {:?}", other.data_type()),
2147                    });
2148                }
2149            };
2150            // PG returns the trigram set sorted lexicographically.
2151            // `extract_trigrams` already returns a BTreeSet so the
2152            // order is canonical.
2153            let trigrams: Vec<Option<String>> = spg_storage::trgm::extract_trigrams(s)
2154                .into_iter()
2155                .map(Some)
2156                .collect();
2157            Ok(Value::TextArray(trigrams))
2158        }
2159        other => Err(EvalError::TypeMismatch {
2160            detail: format!("unknown function `{other}`"),
2161        }),
2162    }
2163}
2164
2165/// v7.12.2 — `ts_rank([weights,] vec, query [, norm])`. v7.12.2
2166/// supports the canonical `(vec, query)` two-arg form mailrs uses;
2167/// optional weight-array / normalisation arguments error with an
2168/// "unsupported" message rather than silently changing semantics.
2169fn fts_ts_rank(args: &[Value]) -> Result<Value, EvalError> {
2170    let (vec, query) = parse_rank_args("ts_rank", args)?;
2171    match (vec, query) {
2172        (None, _) | (_, None) => Ok(Value::Null),
2173        (Some(v), Some(q)) => Ok(Value::Float(f64::from(crate::fts::ts_rank(&v, &q)))),
2174    }
2175}
2176
2177fn fts_ts_rank_cd(args: &[Value]) -> Result<Value, EvalError> {
2178    let (vec, query) = parse_rank_args("ts_rank_cd", args)?;
2179    match (vec, query) {
2180        (None, _) | (_, None) => Ok(Value::Null),
2181        (Some(v), Some(q)) => Ok(Value::Float(f64::from(crate::fts::ts_rank_cd(&v, &q)))),
2182    }
2183}
2184
2185fn parse_rank_args(
2186    name: &str,
2187    args: &[Value],
2188) -> Result<
2189    (
2190        Option<Vec<spg_storage::TsLexeme>>,
2191        Option<spg_storage::TsQueryAst>,
2192    ),
2193    EvalError,
2194> {
2195    if args.len() != 2 {
2196        return Err(EvalError::TypeMismatch {
2197            detail: format!(
2198                "{name}() takes 2 args in v7.12.2 (weights array + normalisation flag are v7.12.x carve-out), got {}",
2199                args.len()
2200            ),
2201        });
2202    }
2203    let vec = match &args[0] {
2204        Value::Null => None,
2205        Value::TsVector(v) => Some(v.clone()),
2206        other => {
2207            return Err(EvalError::TypeMismatch {
2208                detail: format!(
2209                    "{name}() first arg must be tsvector, got {:?}",
2210                    other.data_type()
2211                ),
2212            });
2213        }
2214    };
2215    let query = match &args[1] {
2216        Value::Null => None,
2217        Value::TsQuery(q) => Some(q.clone()),
2218        other => {
2219            return Err(EvalError::TypeMismatch {
2220                detail: format!(
2221                    "{name}() second arg must be tsquery, got {:?}",
2222                    other.data_type()
2223                ),
2224            });
2225        }
2226    };
2227    Ok((vec, query))
2228}
2229
2230/// v7.12.2 — `tsvector @@ tsquery` match operator. Either
2231/// ordering accepted (PG semantics). NULL on either side → NULL.
2232/// Anything that isn't tsvector/tsquery on either side is a type
2233/// mismatch. Returns BOOL.
2234fn ts_match(l: Value, r: Value) -> Result<Value, EvalError> {
2235    let (vec, query) = match (l, r) {
2236        (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
2237        (Value::TsVector(v), Value::TsQuery(q)) => (v, q),
2238        (Value::TsQuery(q), Value::TsVector(v)) => (v, q),
2239        (l, r) => {
2240            return Err(EvalError::TypeMismatch {
2241                detail: format!(
2242                    "@@ requires (tsvector, tsquery), got ({:?}, {:?})",
2243                    l.data_type(),
2244                    r.data_type()
2245                ),
2246            });
2247        }
2248    };
2249    Ok(Value::Bool(crate::fts::ts_query_matches(&vec, &query)))
2250}
2251
2252/// v7.12.1 — `to_tsvector([config,] text)`. With one arg the
2253/// session-resolved `default_text_search_config` is used (defaults
2254/// to `simple` when unset); with two args the first picks the
2255/// config. NULL text → NULL.
2256fn fts_to_tsvector(args: &[Value], ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
2257    let (config, text) = parse_fts_args("to_tsvector", args, ctx)?;
2258    match text {
2259        None => Ok(Value::Null),
2260        Some(t) => Ok(Value::TsVector(crate::fts::to_tsvector(config, &t))),
2261    }
2262}
2263
2264fn fts_plainto_tsquery(args: &[Value], ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
2265    let (config, text) = parse_fts_args("plainto_tsquery", args, ctx)?;
2266    match text {
2267        None => Ok(Value::Null),
2268        Some(t) => Ok(Value::TsQuery(crate::fts::plainto_tsquery(config, &t))),
2269    }
2270}
2271
2272fn fts_phraseto_tsquery(args: &[Value], ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
2273    let (config, text) = parse_fts_args("phraseto_tsquery", args, ctx)?;
2274    match text {
2275        None => Ok(Value::Null),
2276        Some(t) => Ok(Value::TsQuery(crate::fts::phraseto_tsquery(config, &t))),
2277    }
2278}
2279
2280fn fts_websearch_to_tsquery(args: &[Value], ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
2281    let (config, text) = parse_fts_args("websearch_to_tsquery", args, ctx)?;
2282    match text {
2283        None => Ok(Value::Null),
2284        Some(t) => Ok(Value::TsQuery(crate::fts::websearch_to_tsquery(config, &t))),
2285    }
2286}
2287
2288fn fts_to_tsquery(args: &[Value], ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
2289    let (config, text) = parse_fts_args("to_tsquery", args, ctx)?;
2290    match text {
2291        None => Ok(Value::Null),
2292        Some(t) => Ok(Value::TsQuery(crate::fts::to_tsquery(config, &t)?)),
2293    }
2294}
2295
2296/// Parse the `(config, text)` / `(text)` argument pair shared by
2297/// all FTS builders. Returns the resolved config + the text
2298/// payload (None when text is NULL). The one-arg form pulls the
2299/// config from the session's `default_text_search_config`.
2300fn parse_fts_args(
2301    name: &str,
2302    args: &[Value],
2303    ctx: &EvalContext<'_>,
2304) -> Result<(crate::fts::TsConfig, Option<String>), EvalError> {
2305    let (config_arg, text_arg) = match args {
2306        [t] => (None, t),
2307        [c, t] => (Some(c), t),
2308        _ => {
2309            return Err(EvalError::TypeMismatch {
2310                detail: format!("{name}() takes 1 or 2 args, got {}", args.len()),
2311            });
2312        }
2313    };
2314    let config = match config_arg {
2315        None => match ctx.default_text_search_config {
2316            Some(name_str) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
2317                EvalError::TypeMismatch {
2318                    detail: format!(
2319                        "text search config not implemented: {name_str:?} (supported: simple, english)"
2320                    ),
2321                }
2322            })?,
2323            None => crate::fts::TsConfig::Simple,
2324        },
2325        Some(Value::Null) => return Ok((crate::fts::TsConfig::Simple, None)),
2326        Some(Value::Text(name_str)) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
2327            EvalError::TypeMismatch {
2328                detail: format!(
2329                    "text search config not implemented: {name_str:?} (supported: simple, english)"
2330                ),
2331            }
2332        })?,
2333        Some(other) => {
2334            return Err(EvalError::TypeMismatch {
2335                detail: format!(
2336                    "{name}() config arg must be text, got {:?}",
2337                    other.data_type()
2338                ),
2339            });
2340        }
2341    };
2342    let text = match text_arg {
2343        Value::Null => None,
2344        Value::Text(s) => Some(s.clone()),
2345        other => {
2346            return Err(EvalError::TypeMismatch {
2347                detail: format!(
2348                    "{name}() text arg must be text, got {:?}",
2349                    other.data_type()
2350                ),
2351            });
2352        }
2353    };
2354    Ok((config, text))
2355}
2356
2357/// v6.4.3 — `encode(bytes_as_text, format)`. PG works on bytea
2358/// arguments; SPG's value space treats Text as the byte container
2359/// (raw UTF-8 bytes). Supported formats: base64 (PG default),
2360/// base64url (RFC 4648 §5), base32hex (RFC 4648 §7 extended-hex),
2361/// hex.
2362fn encode_text(args: &[Value]) -> Result<Value, EvalError> {
2363    if args.len() != 2 {
2364        return Err(EvalError::TypeMismatch {
2365            detail: format!("encode() takes 2 args, got {}", args.len()),
2366        });
2367    }
2368    if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
2369        return Ok(Value::Null);
2370    }
2371    let bytes: &[u8] = match &args[0] {
2372        Value::Text(s) => s.as_bytes(),
2373        other => {
2374            return Err(EvalError::TypeMismatch {
2375                detail: format!("encode() expects text bytes, got {:?}", other.data_type()),
2376            });
2377        }
2378    };
2379    let fmt = match &args[1] {
2380        Value::Text(s) => s.to_ascii_lowercase(),
2381        other => {
2382            return Err(EvalError::TypeMismatch {
2383                detail: format!("encode() format must be text, got {:?}", other.data_type()),
2384            });
2385        }
2386    };
2387    let out = match fmt.as_str() {
2388        "base64" => b64_encode(bytes, B64_STD),
2389        "base64url" => b64_encode(bytes, B64_URL),
2390        "base32hex" => b32hex_encode(bytes),
2391        "hex" => hex_encode(bytes),
2392        other => {
2393            return Err(EvalError::TypeMismatch {
2394                detail: format!("encode(): unknown format `{other}`"),
2395            });
2396        }
2397    };
2398    Ok(Value::Text(out))
2399}
2400
2401/// v6.4.3 — `decode(text, format)`. Inverse of `encode`; returns
2402/// Text containing the raw decoded bytes (caller may CAST to bytea
2403/// equivalent if SPG adds bytea later).
2404fn decode_text(args: &[Value]) -> Result<Value, EvalError> {
2405    if args.len() != 2 {
2406        return Err(EvalError::TypeMismatch {
2407            detail: format!("decode() takes 2 args, got {}", args.len()),
2408        });
2409    }
2410    if matches!(args[0], Value::Null) || matches!(args[1], Value::Null) {
2411        return Ok(Value::Null);
2412    }
2413    let text = match &args[0] {
2414        Value::Text(s) => s.as_str(),
2415        other => {
2416            return Err(EvalError::TypeMismatch {
2417                detail: format!("decode() expects text, got {:?}", other.data_type()),
2418            });
2419        }
2420    };
2421    let fmt = match &args[1] {
2422        Value::Text(s) => s.to_ascii_lowercase(),
2423        other => {
2424            return Err(EvalError::TypeMismatch {
2425                detail: format!("decode() format must be text, got {:?}", other.data_type()),
2426            });
2427        }
2428    };
2429    let bytes = match fmt.as_str() {
2430        "base64" => b64_decode(text, B64_STD)?,
2431        "base64url" => b64_decode(text, B64_URL)?,
2432        "base32hex" => b32hex_decode(text)?,
2433        "hex" => hex_decode(text)?,
2434        other => {
2435            return Err(EvalError::TypeMismatch {
2436                detail: format!("decode(): unknown format `{other}`"),
2437            });
2438        }
2439    };
2440    let s = String::from_utf8(bytes).map_err(|_| EvalError::TypeMismatch {
2441        detail: "decode(): result bytes are not valid UTF-8 (SPG stores raw bytes as Text)".into(),
2442    })?;
2443    Ok(Value::Text(s))
2444}
2445
2446/// v6.4.3 — `error_on_null(v)`. Returns `v` unchanged if non-NULL;
2447/// errors otherwise. Convenience to assert NOT NULL inside an
2448/// expression without wrapping it in COALESCE + raise hacks.
2449fn error_on_null(args: &[Value]) -> Result<Value, EvalError> {
2450    if args.len() != 1 {
2451        return Err(EvalError::TypeMismatch {
2452            detail: format!("error_on_null() takes 1 arg, got {}", args.len()),
2453        });
2454    }
2455    if matches!(args[0], Value::Null) {
2456        return Err(EvalError::TypeMismatch {
2457            detail: "error_on_null(): argument is NULL".into(),
2458        });
2459    }
2460    Ok(args[0].clone())
2461}
2462
2463// ── byte-level encoders ───────────────────────────────────────────
2464
2465const B64_STD: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2466const B64_URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2467const B32HEX_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHIJKLMNOPQRSTUV";
2468
2469fn b64_encode(bytes: &[u8], alpha: &[u8; 64]) -> String {
2470    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
2471    let mut i = 0;
2472    while i + 3 <= bytes.len() {
2473        let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | (bytes[i + 2] as u32);
2474        out.push(alpha[((n >> 18) & 0x3f) as usize] as char);
2475        out.push(alpha[((n >> 12) & 0x3f) as usize] as char);
2476        out.push(alpha[((n >> 6) & 0x3f) as usize] as char);
2477        out.push(alpha[(n & 0x3f) as usize] as char);
2478        i += 3;
2479    }
2480    let rem = bytes.len() - i;
2481    if rem == 1 {
2482        let n = (bytes[i] as u32) << 16;
2483        out.push(alpha[((n >> 18) & 0x3f) as usize] as char);
2484        out.push(alpha[((n >> 12) & 0x3f) as usize] as char);
2485        out.push('=');
2486        out.push('=');
2487    } else if rem == 2 {
2488        let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
2489        out.push(alpha[((n >> 18) & 0x3f) as usize] as char);
2490        out.push(alpha[((n >> 12) & 0x3f) as usize] as char);
2491        out.push(alpha[((n >> 6) & 0x3f) as usize] as char);
2492        out.push('=');
2493    }
2494    out
2495}
2496
2497fn b64_decode(text: &str, alpha: &[u8; 64]) -> Result<Vec<u8>, EvalError> {
2498    let mut lookup = [255u8; 256];
2499    for (i, &c) in alpha.iter().enumerate() {
2500        lookup[c as usize] = i as u8;
2501    }
2502    let mut out = Vec::with_capacity(text.len() * 3 / 4);
2503    let mut buf: u32 = 0;
2504    let mut bits: u32 = 0;
2505    for c in text.bytes() {
2506        if c == b'=' {
2507            break;
2508        }
2509        if c == b'\n' || c == b'\r' || c == b' ' {
2510            continue;
2511        }
2512        let v = lookup[c as usize];
2513        if v == 255 {
2514            return Err(EvalError::TypeMismatch {
2515                detail: format!("decode(base64): invalid char {:?}", c as char),
2516            });
2517        }
2518        buf = (buf << 6) | v as u32;
2519        bits += 6;
2520        if bits >= 8 {
2521            bits -= 8;
2522            out.push(((buf >> bits) & 0xff) as u8);
2523        }
2524    }
2525    Ok(out)
2526}
2527
2528fn b32hex_encode(bytes: &[u8]) -> String {
2529    let mut out = String::with_capacity((bytes.len() * 8 + 4) / 5);
2530    let mut buf: u64 = 0;
2531    let mut bits: u32 = 0;
2532    for &b in bytes {
2533        buf = (buf << 8) | b as u64;
2534        bits += 8;
2535        while bits >= 5 {
2536            bits -= 5;
2537            out.push(B32HEX_ALPHABET[((buf >> bits) & 0x1f) as usize] as char);
2538        }
2539    }
2540    if bits > 0 {
2541        out.push(B32HEX_ALPHABET[((buf << (5 - bits)) & 0x1f) as usize] as char);
2542    }
2543    // Pad to multiple of 8.
2544    while out.len() % 8 != 0 {
2545        out.push('=');
2546    }
2547    out
2548}
2549
2550fn b32hex_decode(text: &str) -> Result<Vec<u8>, EvalError> {
2551    let mut lookup = [255u8; 256];
2552    for (i, &c) in B32HEX_ALPHABET.iter().enumerate() {
2553        lookup[c as usize] = i as u8;
2554        // base32hex is case-insensitive — also map lowercase.
2555        let lower = (c as char).to_ascii_lowercase() as u8;
2556        lookup[lower as usize] = i as u8;
2557    }
2558    let mut out = Vec::with_capacity(text.len() * 5 / 8);
2559    let mut buf: u64 = 0;
2560    let mut bits: u32 = 0;
2561    for c in text.bytes() {
2562        if c == b'=' {
2563            break;
2564        }
2565        if c == b'\n' || c == b'\r' || c == b' ' {
2566            continue;
2567        }
2568        let v = lookup[c as usize];
2569        if v == 255 {
2570            return Err(EvalError::TypeMismatch {
2571                detail: format!("decode(base32hex): invalid char {:?}", c as char),
2572            });
2573        }
2574        buf = (buf << 5) | v as u64;
2575        bits += 5;
2576        if bits >= 8 {
2577            bits -= 8;
2578            out.push(((buf >> bits) & 0xff) as u8);
2579        }
2580    }
2581    Ok(out)
2582}
2583
2584fn hex_encode(bytes: &[u8]) -> String {
2585    const HEX: &[u8; 16] = b"0123456789abcdef";
2586    let mut out = String::with_capacity(bytes.len() * 2);
2587    for &b in bytes {
2588        out.push(HEX[(b >> 4) as usize] as char);
2589        out.push(HEX[(b & 0xf) as usize] as char);
2590    }
2591    out
2592}
2593
2594fn hex_decode(text: &str) -> Result<Vec<u8>, EvalError> {
2595    let trimmed = text.trim();
2596    if trimmed.len() % 2 != 0 {
2597        return Err(EvalError::TypeMismatch {
2598            detail: "decode(hex): input length must be even".into(),
2599        });
2600    }
2601    let mut out = Vec::with_capacity(trimmed.len() / 2);
2602    let mut hi: u8 = 0;
2603    for (i, c) in trimmed.bytes().enumerate() {
2604        let v = match c {
2605            b'0'..=b'9' => c - b'0',
2606            b'a'..=b'f' => c - b'a' + 10,
2607            b'A'..=b'F' => c - b'A' + 10,
2608            _ => {
2609                return Err(EvalError::TypeMismatch {
2610                    detail: format!("decode(hex): invalid char {:?}", c as char),
2611                });
2612            }
2613        };
2614        if i % 2 == 0 {
2615            hi = v;
2616        } else {
2617            out.push((hi << 4) | v);
2618        }
2619    }
2620    Ok(out)
2621}
2622
2623/// `date_part(field_text, source)` — function form of `EXTRACT(field FROM
2624/// source)`. Same component dispatch (DATE / TIMESTAMP / INTERVAL) and
2625/// same `BigInt` return shape; PG returns double precision but we keep the
2626/// integer convention so the runner's `query I` shape works unchanged.
2627fn date_part(args: &[Value]) -> Result<Value, EvalError> {
2628    use spg_sql::ast::ExtractField as F;
2629    if args.len() != 2 {
2630        return Err(EvalError::TypeMismatch {
2631            detail: format!("date_part() takes 2 args, got {}", args.len()),
2632        });
2633    }
2634    if matches!(&args[0], Value::Null) || matches!(&args[1], Value::Null) {
2635        return Ok(Value::Null);
2636    }
2637    let Value::Text(field_name) = &args[0] else {
2638        return Err(EvalError::TypeMismatch {
2639            detail: format!(
2640                "date_part() needs a text field, got {:?}",
2641                args[0].data_type()
2642            ),
2643        });
2644    };
2645    let field = match field_name.to_ascii_lowercase().as_str() {
2646        "year" => F::Year,
2647        "month" => F::Month,
2648        "day" => F::Day,
2649        "hour" => F::Hour,
2650        "minute" => F::Minute,
2651        "second" => F::Second,
2652        "microsecond" | "microseconds" => F::Microsecond,
2653        "epoch" => F::Epoch,
2654        other => {
2655            return Err(EvalError::TypeMismatch {
2656                detail: format!(
2657                    "unknown date_part field {other:?}; \
2658                     supported: year, month, day, hour, minute, second, microsecond"
2659                ),
2660            });
2661        }
2662    };
2663    extract_field(field, &args[1])
2664}
2665
2666/// `age(t1, t2)` — return `t1 - t2` as an INTERVAL. v2.12 produces a
2667/// micros-only interval (no months normalisation) because PG's
2668/// month-justification rule is sensitive to the day-of-month walk and
2669/// adds material complexity for marginal corpus value.
2670///
2671/// `age(t)` (single-arg form) is intentionally unsupported in v2.12:
2672/// the dispatcher errors instead of guessing a clock source. Callers
2673/// who want PG's `age(t)` semantics should write `age(CURRENT_DATE, t)`
2674/// explicitly so the clock reference is visible at the SQL layer.
2675fn age(args: &[Value]) -> Result<Value, EvalError> {
2676    if args.is_empty() || args.len() > 2 {
2677        return Err(EvalError::TypeMismatch {
2678            detail: format!("age() takes 1 or 2 args, got {}", args.len()),
2679        });
2680    }
2681    if args.iter().any(|v| matches!(v, Value::Null)) {
2682        return Ok(Value::Null);
2683    }
2684    // Coerce to TIMESTAMP micros — DATE lifts to midnight; TIMESTAMP
2685    // stays as-is; anything else errors.
2686    let to_micros = |v: &Value| -> Result<i64, EvalError> {
2687        match v {
2688            Value::Timestamp(t) => Ok(*t),
2689            Value::Date(d) => Ok(i64::from(*d) * 86_400_000_000),
2690            other => Err(EvalError::TypeMismatch {
2691                detail: format!("age() needs DATE or TIMESTAMP, got {:?}", other.data_type()),
2692            }),
2693        }
2694    };
2695    if args.len() == 1 {
2696        return Err(EvalError::TypeMismatch {
2697            detail: "single-arg age() is unsupported in v2.12 \
2698                     (use age(CURRENT_DATE, t) explicitly)"
2699                .into(),
2700        });
2701    }
2702    let a = to_micros(&args[0])?;
2703    let b = to_micros(&args[1])?;
2704    let delta = a.checked_sub(b).ok_or(EvalError::TypeMismatch {
2705        detail: "age() subtraction overflows i64 microseconds".into(),
2706    })?;
2707    Ok(Value::Interval {
2708        months: 0,
2709        micros: delta,
2710    })
2711}
2712
2713// `to_char(value, format)` — render a DATE / TIMESTAMP through a PG
2714// format template. Supports the high-traffic placeholders:
2715//   YYYY YY MM Mon Month DD HH24 HH12 MI SS MS US AM PM
2716// Unrecognised characters pass through literally so the template's
2717// punctuation ('-', ':', ' ', '/') needs no escape mechanism.
2718
2719// ─── v7.17.0 Phase 7 — INET / CIDR text helpers ───────────────────────
2720//
2721// SPG stores network address types as Text. The host() / network() /
2722// masklen() helpers parse the textual `addr[/mask]` form and return
2723// the constituent pieces, matching PG's contract for the dominant
2724// customer surface (Django ORM / Rails ORM normalisation).
2725
2726fn inet_host(args: &[Value]) -> Result<Value, EvalError> {
2727    let s = match args {
2728        [Value::Text(s)] => s.clone(),
2729        [Value::Null] => return Ok(Value::Null),
2730        _ => {
2731            return Err(EvalError::TypeMismatch {
2732                detail: alloc::format!("host() takes one TEXT arg, got {} args", args.len()),
2733            });
2734        }
2735    };
2736    let host = s.split('/').next().unwrap_or("").to_string();
2737    Ok(Value::Text(host))
2738}
2739
2740fn inet_network(args: &[Value]) -> Result<Value, EvalError> {
2741    let s = match args {
2742        [Value::Text(s)] => s.clone(),
2743        [Value::Null] => return Ok(Value::Null),
2744        _ => {
2745            return Err(EvalError::TypeMismatch {
2746                detail: alloc::format!("network() takes one TEXT arg, got {} args", args.len()),
2747            });
2748        }
2749    };
2750    // For a `host/mask` form return the masked-network address.
2751    // SPG ships the simple "drop trailing octets per byte" path
2752    // for IPv4; full bit-level masking is out of v7.17 scope.
2753    let mut split = s.splitn(2, '/');
2754    let host = split.next().unwrap_or("").to_string();
2755    let mask: u32 = split.next().and_then(|m| m.parse().ok()).unwrap_or(32);
2756    if !host.contains('.') {
2757        // IPv6 / MACADDR — return the input unmasked.
2758        return Ok(Value::Text(s));
2759    }
2760    let octets: Vec<&str> = host.split('.').collect();
2761    if octets.len() != 4 {
2762        return Ok(Value::Text(s));
2763    }
2764    let keep_bytes = ((mask + 7) / 8) as usize;
2765    let mut out = alloc::string::String::new();
2766    for (i, oct) in octets.iter().enumerate() {
2767        if i > 0 {
2768            out.push('.');
2769        }
2770        if i < keep_bytes {
2771            out.push_str(oct);
2772        } else {
2773            out.push('0');
2774        }
2775    }
2776    out.push('/');
2777    out.push_str(&mask.to_string());
2778    Ok(Value::Text(out))
2779}
2780
2781fn inet_masklen(args: &[Value]) -> Result<Value, EvalError> {
2782    let s = match args {
2783        [Value::Text(s)] => s.clone(),
2784        [Value::Null] => return Ok(Value::Null),
2785        _ => {
2786            return Err(EvalError::TypeMismatch {
2787                detail: alloc::format!("masklen() takes one TEXT arg, got {} args", args.len()),
2788            });
2789        }
2790    };
2791    let mask: i32 = s
2792        .split_once('/')
2793        .and_then(|(_, m)| m.parse().ok())
2794        .unwrap_or(32);
2795    Ok(Value::Int(mask))
2796}
2797
2798// ─── v7.17.0 Phase 3.P0-47 — INET / CIDR containment + overlap ────────
2799//
2800// SPG stores INET / CIDR as Text (Phase 7 design); these helpers parse
2801// the textual `addr[/mask]` form into a (family, bytes, prefix_bits)
2802// triple and implement PG's network-comparison operators on that
2803// representation.
2804//
2805// PG semantics:
2806//   * `<<`  — strictly contained-in (LHS ⊊ RHS)
2807//   * `<<=` — contained-in-or-equal (LHS ⊆ RHS)
2808//   * `>>`, `>>=` — mirrors of the above
2809//   * `&&`  — overlap (either LHS ⊆ RHS or RHS ⊆ LHS)
2810//
2811// NULL on either side → NULL (3VL). Mixed family (v4 vs v6) is never
2812// contained / never overlaps but is not an error — same as PG.
2813
2814/// Parsed inet network: address bytes (4 for v4, 16 for v6) and the
2815/// network prefix length in bits.
2816struct InetNet {
2817    bytes: [u8; 16],
2818    /// 4 for IPv4, 16 for IPv6.
2819    family_bytes: u8,
2820    /// 0..=32 for v4, 0..=128 for v6.
2821    prefix_bits: u8,
2822}
2823
2824fn parse_inet_text(s: &str) -> Option<InetNet> {
2825    let mut split = s.splitn(2, '/');
2826    let host = split.next()?;
2827    let mask_str = split.next();
2828    if host.contains(':') {
2829        let bytes = parse_ipv6(host)?;
2830        let prefix_bits = match mask_str {
2831            Some(m) => m.parse::<u8>().ok().filter(|&n| n <= 128)?,
2832            None => 128,
2833        };
2834        let mut out = [0u8; 16];
2835        out.copy_from_slice(&bytes);
2836        Some(InetNet {
2837            bytes: out,
2838            family_bytes: 16,
2839            prefix_bits,
2840        })
2841    } else {
2842        let bytes = parse_ipv4(host)?;
2843        let prefix_bits = match mask_str {
2844            Some(m) => m.parse::<u8>().ok().filter(|&n| n <= 32)?,
2845            None => 32,
2846        };
2847        let mut out = [0u8; 16];
2848        out[..4].copy_from_slice(&bytes);
2849        Some(InetNet {
2850            bytes: out,
2851            family_bytes: 4,
2852            prefix_bits,
2853        })
2854    }
2855}
2856
2857fn parse_ipv4(s: &str) -> Option<[u8; 4]> {
2858    let parts: Vec<&str> = s.split('.').collect();
2859    if parts.len() != 4 {
2860        return None;
2861    }
2862    let mut out = [0u8; 4];
2863    for (i, p) in parts.iter().enumerate() {
2864        out[i] = p.parse::<u8>().ok()?;
2865    }
2866    Some(out)
2867}
2868
2869fn parse_ipv6(s: &str) -> Option<[u8; 16]> {
2870    // Split on the `::` shorthand at most once.
2871    let (head, tail) = match s.find("::") {
2872        Some(idx) => (&s[..idx], Some(&s[idx + 2..])),
2873        None => (s, None),
2874    };
2875    let head_groups: Vec<&str> = if head.is_empty() {
2876        Vec::new()
2877    } else {
2878        head.split(':').collect()
2879    };
2880    let tail_groups: Vec<&str> = match tail {
2881        Some(t) if !t.is_empty() => t.split(':').collect(),
2882        _ => Vec::new(),
2883    };
2884    let head_len = head_groups.len();
2885    let tail_len = tail_groups.len();
2886    // Without `::` we need exactly 8 groups; with `::` we need ≤ 7.
2887    if tail.is_none() {
2888        if head_len != 8 {
2889            return None;
2890        }
2891    } else if head_len + tail_len > 7 {
2892        return None;
2893    }
2894    let mut words = [0u16; 8];
2895    for (i, g) in head_groups.iter().enumerate() {
2896        words[i] = u16::from_str_radix(g, 16).ok()?;
2897    }
2898    let tail_start = 8 - tail_len;
2899    for (i, g) in tail_groups.iter().enumerate() {
2900        words[tail_start + i] = u16::from_str_radix(g, 16).ok()?;
2901    }
2902    let mut out = [0u8; 16];
2903    for (i, w) in words.iter().enumerate() {
2904        out[i * 2] = (w >> 8) as u8;
2905        out[i * 2 + 1] = (w & 0xff) as u8;
2906    }
2907    Some(out)
2908}
2909
2910/// Compare the first `prefix_bits` bits of `a` and `b`. Returns true if
2911/// they match. `prefix_bits` must not exceed the family size.
2912fn network_prefix_eq(a: &InetNet, b: &InetNet, prefix_bits: u8) -> bool {
2913    let full_bytes = (prefix_bits / 8) as usize;
2914    if a.bytes[..full_bytes] != b.bytes[..full_bytes] {
2915        return false;
2916    }
2917    let extra = prefix_bits % 8;
2918    if extra == 0 {
2919        return true;
2920    }
2921    let mask: u8 = 0xff << (8 - extra);
2922    (a.bytes[full_bytes] & mask) == (b.bytes[full_bytes] & mask)
2923}
2924
2925/// True iff network `a` is fully contained in network `b` (a ⊆ b).
2926fn inet_contained_eq(a: &InetNet, b: &InetNet) -> bool {
2927    if a.family_bytes != b.family_bytes {
2928        return false;
2929    }
2930    if a.prefix_bits < b.prefix_bits {
2931        return false;
2932    }
2933    network_prefix_eq(a, b, b.prefix_bits)
2934}
2935
2936/// True iff a and b are exactly the same network (same family + same
2937/// prefix + same masked address).
2938fn inet_networks_equal(a: &InetNet, b: &InetNet) -> bool {
2939    if a.family_bytes != b.family_bytes {
2940        return false;
2941    }
2942    if a.prefix_bits != b.prefix_bits {
2943        return false;
2944    }
2945    network_prefix_eq(a, b, a.prefix_bits)
2946}
2947
2948fn inet_op_bool_result(op: BinOp, l: &Value, r: &Value) -> Result<Value, EvalError> {
2949    if matches!(l, Value::Null) || matches!(r, Value::Null) {
2950        return Ok(Value::Null);
2951    }
2952    let (lt, rt) = match (l, r) {
2953        (Value::Text(a), Value::Text(b)) => (a, b),
2954        _ => {
2955            return Err(EvalError::TypeMismatch {
2956                detail: format!(
2957                    "inet operator requires TEXT/INET operands, got {:?} and {:?}",
2958                    l.data_type(),
2959                    r.data_type()
2960                ),
2961            });
2962        }
2963    };
2964    let a = parse_inet_text(lt).ok_or_else(|| EvalError::TypeMismatch {
2965        detail: format!("invalid inet text: {:?}", lt),
2966    })?;
2967    let b = parse_inet_text(rt).ok_or_else(|| EvalError::TypeMismatch {
2968        detail: format!("invalid inet text: {:?}", rt),
2969    })?;
2970    let result = match op {
2971        BinOp::InetContainedByEq => inet_contained_eq(&a, &b),
2972        BinOp::InetContainedBy => inet_contained_eq(&a, &b) && !inet_networks_equal(&a, &b),
2973        BinOp::InetContainsEq => inet_contained_eq(&b, &a),
2974        BinOp::InetContains => inet_contained_eq(&b, &a) && !inet_networks_equal(&a, &b),
2975        BinOp::InetOverlap => inet_contained_eq(&a, &b) || inet_contained_eq(&b, &a),
2976        _ => unreachable!("inet_op_bool_result called with non-inet op"),
2977    };
2978    Ok(Value::Bool(result))
2979}
2980
2981// ─── v7.17.0 Phase 3.7 — minimal POSIX-ERE-shaped regex matcher ───────
2982//
2983// SPG-engine is `#![no_std]` and has no external regex dependency, so
2984// this module hand-implements the subset of PG's regex needed by the
2985// dominant customer patterns. Supported syntax:
2986//
2987//   * literal characters (with `\.`, `\*`, `\+`, `\?`, `\(`, `\)`,
2988//     `\[`, `\]`, `\\`, `\^`, `\$`, `\|` escapes)
2989//   * `.` — any single character
2990//   * `*`, `+`, `?` — greedy quantifiers
2991//   * character classes: `[abc]`, `[^abc]`, `[a-z0-9_]`
2992//   * shortcut classes: `\d` `\D` `\w` `\W` `\s` `\S`
2993//   * anchors `^` `$`
2994//   * non-capturing groups `(...)`
2995//   * alternation `|`
2996//
2997// NOT supported in v7.17 (errors clearly):
2998//   * backreferences `\1`
2999//   * lookaround `(?=…)` `(?<=…)`
3000//   * named captures
3001//   * inline flag groups `(?i)`
3002//   * lazy quantifiers `*?` `+?` `??` — patterns containing `?` after
3003//     a quantifier are accepted but interpreted as the greedy form
3004//     (this is the v7.17 stop-gap; customers needing lazy semantics
3005//     should preprocess the pattern)
3006//   * counted repetition `{n,m}`
3007//
3008// The matcher uses a backtracking NFA-shaped walk; performance is fine
3009// for the small strings PG regex functions usually operate on.
3010
3011#[derive(Debug, Clone)]
3012enum ReNode {
3013    /// Single literal byte. ASCII fast-path; non-ASCII falls through
3014    /// to Any since the engine doesn't decode UTF-8 here.
3015    Literal(char),
3016    /// Any single character.
3017    AnyChar,
3018    /// Character class: (positive members list, negated flag).
3019    Class {
3020        members: Vec<ClassMember>,
3021        negated: bool,
3022    },
3023    /// Anchor start.
3024    Start,
3025    /// Anchor end.
3026    End,
3027    /// Greedy quantifier.
3028    Quant {
3029        inner: Box<ReNode>,
3030        min: usize,
3031        max: Option<usize>,
3032    },
3033    /// Concatenation of sub-nodes.
3034    Concat(Vec<ReNode>),
3035    /// Alternation.
3036    Alt(Vec<ReNode>),
3037}
3038
3039#[derive(Debug, Clone)]
3040enum ClassMember {
3041    Single(char),
3042    Range(char, char),
3043}
3044
3045fn re_compile(pat: &str) -> Result<ReNode, EvalError> {
3046    let chars: Vec<char> = pat.chars().collect();
3047    let mut p = 0;
3048    let n = re_parse_alt(&chars, &mut p)?;
3049    if p != chars.len() {
3050        return Err(EvalError::TypeMismatch {
3051            detail: alloc::format!("regex compile: trailing chars at pos {p} in {pat:?}"),
3052        });
3053    }
3054    Ok(n)
3055}
3056
3057fn re_parse_alt(chars: &[char], p: &mut usize) -> Result<ReNode, EvalError> {
3058    let mut branches = alloc::vec![re_parse_concat(chars, p)?];
3059    while *p < chars.len() && chars[*p] == '|' {
3060        *p += 1;
3061        branches.push(re_parse_concat(chars, p)?);
3062    }
3063    if branches.len() == 1 {
3064        Ok(branches.pop().unwrap())
3065    } else {
3066        Ok(ReNode::Alt(branches))
3067    }
3068}
3069
3070fn re_parse_concat(chars: &[char], p: &mut usize) -> Result<ReNode, EvalError> {
3071    let mut items: Vec<ReNode> = Vec::new();
3072    while *p < chars.len() {
3073        let c = chars[*p];
3074        if c == '|' || c == ')' {
3075            break;
3076        }
3077        let atom = re_parse_atom(chars, p)?;
3078        // Optional quantifier suffix.
3079        let quantified = if *p < chars.len() {
3080            match chars[*p] {
3081                '*' => {
3082                    *p += 1;
3083                    // v7.17 stop-gap: tolerate `*?` lazy quantifier
3084                    // by treating it as greedy. Skip the trailing
3085                    // `?` if present.
3086                    if *p < chars.len() && chars[*p] == '?' {
3087                        *p += 1;
3088                    }
3089                    ReNode::Quant {
3090                        inner: Box::new(atom),
3091                        min: 0,
3092                        max: None,
3093                    }
3094                }
3095                '+' => {
3096                    *p += 1;
3097                    if *p < chars.len() && chars[*p] == '?' {
3098                        *p += 1;
3099                    }
3100                    ReNode::Quant {
3101                        inner: Box::new(atom),
3102                        min: 1,
3103                        max: None,
3104                    }
3105                }
3106                '?' => {
3107                    *p += 1;
3108                    ReNode::Quant {
3109                        inner: Box::new(atom),
3110                        min: 0,
3111                        max: Some(1),
3112                    }
3113                }
3114                _ => atom,
3115            }
3116        } else {
3117            atom
3118        };
3119        items.push(quantified);
3120    }
3121    if items.len() == 1 {
3122        Ok(items.pop().unwrap())
3123    } else {
3124        Ok(ReNode::Concat(items))
3125    }
3126}
3127
3128fn re_parse_atom(chars: &[char], p: &mut usize) -> Result<ReNode, EvalError> {
3129    let c = chars[*p];
3130    match c {
3131        '(' => {
3132            *p += 1;
3133            let inner = re_parse_alt(chars, p)?;
3134            if *p >= chars.len() || chars[*p] != ')' {
3135                return Err(EvalError::TypeMismatch {
3136                    detail: "regex compile: unmatched '('".into(),
3137                });
3138            }
3139            *p += 1;
3140            Ok(inner)
3141        }
3142        '[' => {
3143            *p += 1;
3144            let mut negated = false;
3145            if *p < chars.len() && chars[*p] == '^' {
3146                negated = true;
3147                *p += 1;
3148            }
3149            let mut members: Vec<ClassMember> = Vec::new();
3150            while *p < chars.len() && chars[*p] != ']' {
3151                let start = chars[*p];
3152                *p += 1;
3153                if *p + 1 < chars.len() && chars[*p] == '-' && chars[*p + 1] != ']' {
3154                    let end = chars[*p + 1];
3155                    *p += 2;
3156                    members.push(ClassMember::Range(start, end));
3157                } else {
3158                    members.push(ClassMember::Single(start));
3159                }
3160            }
3161            if *p >= chars.len() {
3162                return Err(EvalError::TypeMismatch {
3163                    detail: "regex compile: unmatched '['".into(),
3164                });
3165            }
3166            *p += 1; // consume ]
3167            Ok(ReNode::Class { members, negated })
3168        }
3169        '.' => {
3170            *p += 1;
3171            Ok(ReNode::AnyChar)
3172        }
3173        '^' => {
3174            *p += 1;
3175            Ok(ReNode::Start)
3176        }
3177        '$' => {
3178            *p += 1;
3179            Ok(ReNode::End)
3180        }
3181        '\\' => {
3182            *p += 1;
3183            if *p >= chars.len() {
3184                return Err(EvalError::TypeMismatch {
3185                    detail: "regex compile: dangling backslash".into(),
3186                });
3187            }
3188            let esc = chars[*p];
3189            *p += 1;
3190            match esc {
3191                'd' => Ok(ReNode::Class {
3192                    members: alloc::vec![ClassMember::Range('0', '9')],
3193                    negated: false,
3194                }),
3195                'D' => Ok(ReNode::Class {
3196                    members: alloc::vec![ClassMember::Range('0', '9')],
3197                    negated: true,
3198                }),
3199                'w' => Ok(ReNode::Class {
3200                    members: alloc::vec![
3201                        ClassMember::Range('a', 'z'),
3202                        ClassMember::Range('A', 'Z'),
3203                        ClassMember::Range('0', '9'),
3204                        ClassMember::Single('_'),
3205                    ],
3206                    negated: false,
3207                }),
3208                'W' => Ok(ReNode::Class {
3209                    members: alloc::vec![
3210                        ClassMember::Range('a', 'z'),
3211                        ClassMember::Range('A', 'Z'),
3212                        ClassMember::Range('0', '9'),
3213                        ClassMember::Single('_'),
3214                    ],
3215                    negated: true,
3216                }),
3217                's' => Ok(ReNode::Class {
3218                    members: alloc::vec![
3219                        ClassMember::Single(' '),
3220                        ClassMember::Single('\t'),
3221                        ClassMember::Single('\n'),
3222                        ClassMember::Single('\r'),
3223                    ],
3224                    negated: false,
3225                }),
3226                'S' => Ok(ReNode::Class {
3227                    members: alloc::vec![
3228                        ClassMember::Single(' '),
3229                        ClassMember::Single('\t'),
3230                        ClassMember::Single('\n'),
3231                        ClassMember::Single('\r'),
3232                    ],
3233                    negated: true,
3234                }),
3235                other => Ok(ReNode::Literal(other)),
3236            }
3237        }
3238        other => {
3239            *p += 1;
3240            Ok(ReNode::Literal(other))
3241        }
3242    }
3243}
3244
3245fn class_matches(member: &ClassMember, c: char) -> bool {
3246    match member {
3247        ClassMember::Single(s) => *s == c,
3248        ClassMember::Range(a, b) => c >= *a && c <= *b,
3249    }
3250}
3251
3252/// Try to match `node` starting at `pos` in `s`. Returns Some(end)
3253/// of the matched span (exclusive), or None if no match. Greedy
3254/// backtracking: each quantifier tries the longest viable repeat
3255/// and shrinks if the tail doesn't fit.
3256fn re_match_at(node: &ReNode, s: &[char], pos: usize) -> Option<usize> {
3257    match node {
3258        ReNode::Literal(c) => {
3259            if s.get(pos).copied() == Some(*c) {
3260                Some(pos + 1)
3261            } else {
3262                None
3263            }
3264        }
3265        ReNode::AnyChar => {
3266            if pos < s.len() && s[pos] != '\n' {
3267                Some(pos + 1)
3268            } else {
3269                None
3270            }
3271        }
3272        ReNode::Class { members, negated } => {
3273            let c = *s.get(pos)?;
3274            let hit = members.iter().any(|m| class_matches(m, c));
3275            if hit ^ negated { Some(pos + 1) } else { None }
3276        }
3277        ReNode::Start => {
3278            if pos == 0 {
3279                Some(pos)
3280            } else {
3281                None
3282            }
3283        }
3284        ReNode::End => {
3285            if pos == s.len() {
3286                Some(pos)
3287            } else {
3288                None
3289            }
3290        }
3291        ReNode::Concat(items) => {
3292            let mut p = pos;
3293            for it in items {
3294                p = re_match_at(it, s, p)?;
3295            }
3296            Some(p)
3297        }
3298        ReNode::Alt(branches) => {
3299            for b in branches {
3300                if let Some(p) = re_match_at(b, s, pos) {
3301                    return Some(p);
3302                }
3303            }
3304            None
3305        }
3306        ReNode::Quant { inner, min, max } => {
3307            // Greedy: gather as many matches as possible, then
3308            // shrink. v7.17 stop-gap doesn't continue the outer
3309            // tail match (we're at a leaf in concat already), so
3310            // we just return the longest match.
3311            let mut count = 0usize;
3312            let mut p = pos;
3313            loop {
3314                if let Some(cap) = max {
3315                    if count >= *cap {
3316                        break;
3317                    }
3318                }
3319                match re_match_at(inner, s, p) {
3320                    Some(np) if np > p => {
3321                        p = np;
3322                        count += 1;
3323                    }
3324                    _ => break,
3325                }
3326            }
3327            if count < *min {
3328                return None;
3329            }
3330            Some(p)
3331        }
3332    }
3333}
3334
3335/// Find the first match of `node` in `s`, starting at or after
3336/// `from`. Returns the (start, end) char positions of the match.
3337fn re_find(node: &ReNode, s: &[char], from: usize) -> Option<(usize, usize)> {
3338    let mut start = from;
3339    loop {
3340        if let Some(end) = re_match_at(node, s, start) {
3341            return Some((start, end));
3342        }
3343        if start >= s.len() {
3344            return None;
3345        }
3346        start += 1;
3347    }
3348}
3349
3350/// v7.17.0 Phase 3.7 — `regexp_matches(s, pat)` returns the FIRST
3351/// match as a single-element TEXT[]. (PG returns one row per match
3352/// across all captures; SPG simplifies to first-match-only TEXT[].
3353/// The `g` flag form `regexp_matches(s, pat, 'g')` falls through
3354/// to all-matches concatenation as a flat array.)
3355fn regexp_matches(args: &[Value]) -> Result<Value, EvalError> {
3356    let (text, pat, all_matches) = match args.len() {
3357        2 => (text_arg(&args[0])?, text_arg(&args[1])?, false),
3358        3 => {
3359            let flags = text_arg(&args[2])?.unwrap_or_default();
3360            (
3361                text_arg(&args[0])?,
3362                text_arg(&args[1])?,
3363                flags.contains('g'),
3364            )
3365        }
3366        n => {
3367            return Err(EvalError::TypeMismatch {
3368                detail: alloc::format!("regexp_matches() takes 2 or 3 args, got {n}"),
3369            });
3370        }
3371    };
3372    let Some(text) = text else {
3373        return Ok(Value::Null);
3374    };
3375    let Some(pat) = pat else {
3376        return Ok(Value::Null);
3377    };
3378    let node = re_compile(&pat)?;
3379    let chars: Vec<char> = text.chars().collect();
3380    let mut out: Vec<Option<String>> = Vec::new();
3381    let mut from = 0usize;
3382    while let Some((s_pos, e_pos)) = re_find(&node, &chars, from) {
3383        out.push(Some(chars[s_pos..e_pos].iter().collect()));
3384        if !all_matches {
3385            break;
3386        }
3387        // Advance past the match; if zero-width, step one.
3388        from = if e_pos > s_pos { e_pos } else { e_pos + 1 };
3389        if from > chars.len() {
3390            break;
3391        }
3392    }
3393    Ok(Value::TextArray(out))
3394}
3395
3396/// v7.17.0 Phase 3.7 — `regexp_replace(s, pat, repl[, flags])`.
3397/// `flags` containing `g` replaces all matches; absent flag
3398/// replaces only the first match (PG default).
3399fn regexp_replace(args: &[Value]) -> Result<Value, EvalError> {
3400    let (text, pat, repl, flags) = match args.len() {
3401        3 => (
3402            text_arg(&args[0])?,
3403            text_arg(&args[1])?,
3404            text_arg(&args[2])?,
3405            String::new(),
3406        ),
3407        4 => (
3408            text_arg(&args[0])?,
3409            text_arg(&args[1])?,
3410            text_arg(&args[2])?,
3411            text_arg(&args[3])?.unwrap_or_default(),
3412        ),
3413        n => {
3414            return Err(EvalError::TypeMismatch {
3415                detail: alloc::format!("regexp_replace() takes 3 or 4 args, got {n}"),
3416            });
3417        }
3418    };
3419    let Some(text) = text else {
3420        return Ok(Value::Null);
3421    };
3422    let Some(pat) = pat else {
3423        return Ok(Value::Null);
3424    };
3425    let Some(repl) = repl else {
3426        return Ok(Value::Null);
3427    };
3428    let global = flags.contains('g');
3429    let node = re_compile(&pat)?;
3430    let chars: Vec<char> = text.chars().collect();
3431    let mut out = String::with_capacity(text.len());
3432    let mut from = 0usize;
3433    loop {
3434        match re_find(&node, &chars, from) {
3435            Some((s_pos, e_pos)) => {
3436                out.extend(chars[from..s_pos].iter());
3437                out.push_str(&repl);
3438                let step = if e_pos > s_pos { e_pos } else { e_pos + 1 };
3439                from = step;
3440                if !global {
3441                    if from <= chars.len() {
3442                        out.extend(chars[from..].iter());
3443                    }
3444                    return Ok(Value::Text(out));
3445                }
3446                if from > chars.len() {
3447                    break;
3448                }
3449            }
3450            None => {
3451                out.extend(chars[from..].iter());
3452                break;
3453            }
3454        }
3455    }
3456    Ok(Value::Text(out))
3457}
3458
3459/// v7.17.0 Phase 3.7 — `regexp_split_to_array(s, pat)`. Returns
3460/// TEXT[] of the pieces between matches.
3461fn regexp_split_to_array(args: &[Value]) -> Result<Value, EvalError> {
3462    if args.len() != 2 {
3463        return Err(EvalError::TypeMismatch {
3464            detail: alloc::format!("regexp_split_to_array() takes 2 args, got {}", args.len()),
3465        });
3466    }
3467    let text = text_arg(&args[0])?;
3468    let pat = text_arg(&args[1])?;
3469    let Some(text) = text else {
3470        return Ok(Value::Null);
3471    };
3472    let Some(pat) = pat else {
3473        return Ok(Value::Null);
3474    };
3475    let node = re_compile(&pat)?;
3476    let chars: Vec<char> = text.chars().collect();
3477    let mut out: Vec<Option<String>> = Vec::new();
3478    let mut piece_start = 0usize;
3479    let mut from = 0usize;
3480    loop {
3481        match re_find(&node, &chars, from) {
3482            Some((s_pos, e_pos)) => {
3483                let piece: String = chars[piece_start..s_pos].iter().collect();
3484                out.push(Some(piece));
3485                let step = if e_pos > s_pos { e_pos } else { e_pos + 1 };
3486                from = step;
3487                piece_start = step;
3488                if from > chars.len() {
3489                    break;
3490                }
3491            }
3492            None => {
3493                let tail: String = chars[piece_start..].iter().collect();
3494                out.push(Some(tail));
3495                break;
3496            }
3497        }
3498    }
3499    Ok(Value::TextArray(out))
3500}
3501
3502/// Helper: coerce a Value to an Option<String> for regex args. NULL
3503/// propagates as None (caller short-circuits to Value::Null).
3504fn text_arg(v: &Value) -> Result<Option<String>, EvalError> {
3505    match v {
3506        Value::Text(s) => Ok(Some(s.clone())),
3507        Value::Null => Ok(None),
3508        other => Err(EvalError::TypeMismatch {
3509            detail: alloc::format!(
3510                "regex function expects TEXT arg, got {:?}",
3511                other.data_type()
3512            ),
3513        }),
3514    }
3515}
3516
3517// PG trim family: which side to strip.
3518#[derive(Debug, Clone, Copy)]
3519enum TrimSide {
3520    Left,
3521    Right,
3522    Both,
3523}
3524
3525/// PG `left(s, n)` / `right(s, n)` shared implementation. Both
3526/// support negative n which means "all but |n| chars from the
3527/// opposite side". n=0 → ''. Codepoint-counted. NULL → NULL.
3528fn string_left_right(args: &[Value], is_left: bool, fn_name: &str) -> Result<Value, EvalError> {
3529    if args.len() != 2 {
3530        return Err(EvalError::TypeMismatch {
3531            detail: alloc::format!("{fn_name}() takes 2 args, got {}", args.len()),
3532        });
3533    }
3534    if args.iter().any(|v| matches!(v, Value::Null)) {
3535        return Ok(Value::Null);
3536    }
3537    let s = value_to_format_text(&args[0]);
3538    let n = match &args[1] {
3539        Value::SmallInt(x) => i64::from(*x),
3540        Value::Int(x) => i64::from(*x),
3541        Value::BigInt(x) => *x,
3542        other => {
3543            return Err(EvalError::TypeMismatch {
3544                detail: alloc::format!(
3545                    "{fn_name}(): n must be integer, got {:?}",
3546                    other.data_type()
3547                ),
3548            });
3549        }
3550    };
3551    let chars: Vec<char> = s.chars().collect();
3552    let len = chars.len() as i64;
3553    if n == 0 {
3554        return Ok(Value::Text(String::new()));
3555    }
3556    let (start, end) = if is_left {
3557        if n > 0 {
3558            (0usize, (n.min(len)) as usize)
3559        } else {
3560            // left(s, -k) → drop last |k| chars; keep [0..len - k]
3561            let drop = (-n).min(len);
3562            (0usize, (len - drop) as usize)
3563        }
3564    } else if n > 0 {
3565        // right(s, k) → keep last k chars; start = max(0, len-k)
3566        let start = (len - n).max(0);
3567        (start as usize, len as usize)
3568    } else {
3569        // right(s, -k) → drop first |k| chars; keep [k..len]
3570        let drop = (-n).min(len);
3571        (drop as usize, len as usize)
3572    };
3573    if start >= end {
3574        return Ok(Value::Text(String::new()));
3575    }
3576    Ok(Value::Text(chars[start..end].iter().collect()))
3577}
3578
3579/// Compare two values for min/max selection. Returns Equal when
3580/// values are equal (including cross-numeric-width), Less when
3581/// a < b, Greater when a > b. NULL handling is upstream.
3582fn value_cmp_for_min_max(a: &Value, b: &Value) -> core::cmp::Ordering {
3583    use core::cmp::Ordering;
3584    // Integer-widen first (covers SmallInt vs Int vs BigInt).
3585    let a_int = match a {
3586        Value::SmallInt(x) => Some(i64::from(*x)),
3587        Value::Int(x) => Some(i64::from(*x)),
3588        Value::BigInt(x) => Some(*x),
3589        _ => None,
3590    };
3591    let b_int = match b {
3592        Value::SmallInt(x) => Some(i64::from(*x)),
3593        Value::Int(x) => Some(i64::from(*x)),
3594        Value::BigInt(x) => Some(*x),
3595        _ => None,
3596    };
3597    if let (Some(av), Some(bv)) = (a_int, b_int) {
3598        return av.cmp(&bv);
3599    }
3600    // Float-widen.
3601    let a_f = value_to_f64(a);
3602    let b_f = value_to_f64(b);
3603    if let (Some(av), Some(bv)) = (a_f, b_f) {
3604        return av.partial_cmp(&bv).unwrap_or(Ordering::Equal);
3605    }
3606    // Text/Text.
3607    match (a, b) {
3608        (Value::Text(av), Value::Text(bv)) => av.cmp(bv),
3609        (Value::Bytes(av), Value::Bytes(bv)) => av.cmp(bv),
3610        _ => Ordering::Equal,
3611    }
3612}
3613
3614fn value_to_f64(v: &Value) -> Option<f64> {
3615    match v {
3616        Value::Float(x) => Some(*x),
3617        Value::SmallInt(x) => Some(f64::from(*x)),
3618        Value::Int(x) => Some(f64::from(*x)),
3619        Value::BigInt(x) => Some(*x as f64),
3620        Value::Numeric { scaled, scale } => {
3621            Some((*scaled as f64) / f64_powi(10.0, i32::from(*scale)))
3622        }
3623        _ => None,
3624    }
3625}
3626
3627/// PG-style equality for nullif. Handles cross-numeric-width
3628/// comparison (Int vs BigInt vs SmallInt vs Float vs Numeric);
3629/// text matches text exactly; everything else uses derived
3630/// PartialEq.
3631fn values_equal_for_nullif(a: &Value, b: &Value) -> bool {
3632    // Same-type fast path.
3633    if a == b {
3634        return true;
3635    }
3636    // Cross-int widening: SmallInt / Int / BigInt all comparable.
3637    let a_int = match a {
3638        Value::SmallInt(x) => Some(i64::from(*x)),
3639        Value::Int(x) => Some(i64::from(*x)),
3640        Value::BigInt(x) => Some(*x),
3641        _ => None,
3642    };
3643    let b_int = match b {
3644        Value::SmallInt(x) => Some(i64::from(*x)),
3645        Value::Int(x) => Some(i64::from(*x)),
3646        Value::BigInt(x) => Some(*x),
3647        _ => None,
3648    };
3649    if let (Some(a), Some(b)) = (a_int, b_int) {
3650        return a == b;
3651    }
3652    // Float / Numeric: widen to f64.
3653    let a_f = match a {
3654        Value::Float(x) => Some(*x),
3655        Value::SmallInt(x) => Some(f64::from(*x)),
3656        Value::Int(x) => Some(f64::from(*x)),
3657        Value::BigInt(x) => Some(*x as f64),
3658        Value::Numeric { scaled, scale } => {
3659            Some((*scaled as f64) / f64_powi(10.0, i32::from(*scale)))
3660        }
3661        _ => None,
3662    };
3663    let b_f = match b {
3664        Value::Float(x) => Some(*x),
3665        Value::SmallInt(x) => Some(f64::from(*x)),
3666        Value::Int(x) => Some(f64::from(*x)),
3667        Value::BigInt(x) => Some(*x as f64),
3668        Value::Numeric { scaled, scale } => {
3669            Some((*scaled as f64) / f64_powi(10.0, i32::from(*scale)))
3670        }
3671        _ => None,
3672    };
3673    if let (Some(a), Some(b)) = (a_f, b_f) {
3674        return a == b;
3675    }
3676    false
3677}
3678
3679/// no_std-compatible `trunc(x)` for f64 — truncate toward zero.
3680/// `as i64 as f64` already truncates toward zero for the in-range
3681/// case; the |x| > 2^53 branch returns x verbatim because the f64
3682/// is already integer-precision.
3683fn f64_trunc(x: f64) -> f64 {
3684    if x.is_nan() || x.is_infinite() {
3685        return x;
3686    }
3687    if x >= 9_007_199_254_740_992.0 || x <= -9_007_199_254_740_992.0 {
3688        return x;
3689    }
3690    (x as i64) as f64
3691}
3692
3693/// xorshift64* PRNG state — process-static seed advanced on
3694/// every `random()` call. Not cryptographically secure; use
3695/// `gen_random_uuid` / future crypto-RNG functions when
3696/// security matters.
3697static PRNG_STATE: core::sync::atomic::AtomicU64 =
3698    core::sync::atomic::AtomicU64::new(0x2545_F491_4F6C_DD1D);
3699
3700/// Advance the PRNG and return the raw next 64-bit state.
3701/// Shared between `random()` and `gen_random_uuid()`. The CAS
3702/// loop guarantees concurrent callers each see a distinct value
3703/// — important for `gen_random_uuid` collision freedom under
3704/// concurrent INSERTs.
3705fn prng_next_u64() -> u64 {
3706    use core::sync::atomic::Ordering;
3707    let mut x = PRNG_STATE.load(Ordering::Relaxed);
3708    loop {
3709        if x == 0 {
3710            x = 0x2545_F491_4F6C_DD1D;
3711        }
3712        let mut next = x;
3713        next ^= next << 13;
3714        next ^= next >> 7;
3715        next ^= next << 17;
3716        match PRNG_STATE.compare_exchange_weak(x, next, Ordering::Relaxed, Ordering::Relaxed) {
3717            Ok(_) => return next,
3718            Err(seen) => x = seen,
3719        }
3720    }
3721}
3722
3723/// Advance the PRNG and return a uniform double in [0, 1).
3724fn prng_next_f64() -> f64 {
3725    // 53 bits of randomness mapped to [0, 1).
3726    let mantissa = prng_next_u64() >> 11;
3727    let denom = (1u64 << 53) as f64;
3728    mantissa as f64 / denom
3729}
3730
3731/// v7.17.0 — generate a RFC 4122 v4 (random) UUID. Layout: 16
3732/// random bytes with the version nibble (high nibble of byte 6)
3733/// pinned to `0100` (= 4) and the variant top bits (high two bits
3734/// of byte 8) pinned to `10` — exactly what PG's
3735/// `gen_random_uuid()` and the historical uuid-ossp
3736/// `uuid_generate_v4()` produce.
3737pub fn gen_random_uuid_bytes() -> [u8; 16] {
3738    let mut out = [0u8; 16];
3739    let hi = prng_next_u64().to_be_bytes();
3740    let lo = prng_next_u64().to_be_bytes();
3741    out[..8].copy_from_slice(&hi);
3742    out[8..].copy_from_slice(&lo);
3743    // Version 4: top nibble of byte 6 must be 0100.
3744    out[6] = (out[6] & 0x0f) | 0x40;
3745    // Variant 1 (RFC 4122): top two bits of byte 8 must be 10.
3746    out[8] = (out[8] & 0x3f) | 0x80;
3747    out
3748}
3749
3750/// no_std `f64::sqrt(x)` — square root via Newton's method
3751/// (Babylonian). Gives EXACT results for perfect squares
3752/// because the iteration converges to bit-exact precision in
3753/// floating-point. x must be non-negative (caller's contract).
3754fn f64_sqrt(x: f64) -> f64 {
3755    if x == 0.0 || x.is_nan() {
3756        return x;
3757    }
3758    if x.is_infinite() {
3759        return x;
3760    }
3761    // Initial guess via bit manipulation of the exponent: divide
3762    // the exponent by 2. Avoids needing a logarithm for the
3763    // seed and converges in ~5 iterations.
3764    let bits = x.to_bits();
3765    let exp = ((bits >> 52) & 0x7ff) as i64 - 1023;
3766    let new_exp = (exp / 2) + 1023;
3767    let mut guess = f64::from_bits(((new_exp as u64) & 0x7ff) << 52);
3768    // 5 Newton iterations are MORE than enough for f64 precision.
3769    for _ in 0..8 {
3770        guess = 0.5 * (guess + x / guess);
3771    }
3772    guess
3773}
3774
3775/// no_std `f64::exp(x)` — e^x via range-reduction + Taylor
3776/// series. Adequate for power(), exp(), and pseudo-random-ish
3777/// scales the engine uses; ~1e-12 relative error in the
3778/// common range.
3779fn f64_exp(x: f64) -> f64 {
3780    if x.is_nan() {
3781        return x;
3782    }
3783    if x > 709.0 {
3784        return f64::INFINITY;
3785    }
3786    if x < -745.0 {
3787        return 0.0;
3788    }
3789    // exp(x) = 2^k * exp(r) where r = x - k*ln(2), |r| <= ln(2)/2.
3790    const LN2: f64 = 0.6931471805599453;
3791    let k = f64_round_half_away(x / LN2) as i32;
3792    let r = x - (k as f64) * LN2;
3793    // Taylor series for exp(r): sum r^n / n!  (rapid for |r|<0.35)
3794    let mut term = 1.0;
3795    let mut sum = 1.0;
3796    for n in 1..=20 {
3797        term *= r / (n as f64);
3798        sum += term;
3799        if term.abs() < 1e-18 {
3800            break;
3801        }
3802    }
3803    // Multiply by 2^k.
3804    f64_powi(2.0, k) * sum
3805}
3806
3807/// no_std `f64::ln(x)` — natural log via range-reduction +
3808/// atanh series. x must be positive (caller's contract).
3809fn f64_ln(x: f64) -> f64 {
3810    if x <= 0.0 {
3811        return f64::NAN;
3812    }
3813    if x == 1.0 {
3814        return 0.0;
3815    }
3816    // x = 2^k * m where m in [0.5, 1.0). Then ln(x) = k*ln(2) + ln(m).
3817    const LN2: f64 = 0.6931471805599453;
3818    let mut k = 0i32;
3819    let mut m = x;
3820    while m >= 2.0 {
3821        m *= 0.5;
3822        k += 1;
3823    }
3824    while m < 1.0 {
3825        m *= 2.0;
3826        k -= 1;
3827    }
3828    // Now m in [1.0, 2.0). Use atanh series via u = (m-1)/(m+1).
3829    // ln(m) = 2*(u + u^3/3 + u^5/5 + ...). Converges fast.
3830    let u = (m - 1.0) / (m + 1.0);
3831    let u2 = u * u;
3832    let mut term = u;
3833    let mut sum = u;
3834    for k_iter in 1..50 {
3835        term *= u2;
3836        let denom = (2 * k_iter + 1) as f64;
3837        sum += term / denom;
3838        if (term / denom).abs() < 1e-18 {
3839            break;
3840        }
3841    }
3842    2.0 * sum + (k as f64) * LN2
3843}
3844
3845/// no_std `f64::powi` substitute — integer exponent for f64
3846/// base. Uses repeated multiplication; correct for the small
3847/// exponents the rounding / cast code uses (scale up to ±38).
3848fn f64_powi(base: f64, exp: i32) -> f64 {
3849    if exp == 0 {
3850        return 1.0;
3851    }
3852    let mut result = 1.0;
3853    let mut b = if exp > 0 { base } else { 1.0 / base };
3854    let mut e = exp.unsigned_abs();
3855    while e > 0 {
3856        if e & 1 == 1 {
3857            result *= b;
3858        }
3859        e >>= 1;
3860        if e > 0 {
3861            b *= b;
3862        }
3863    }
3864    result
3865}
3866
3867/// no_std-compatible `round(x)` for f64 with half-away-from-zero
3868/// rule (PG NUMERIC semantic — NOT banker's rounding).
3869fn f64_round_half_away(x: f64) -> f64 {
3870    if x.is_nan() || x.is_infinite() {
3871        return x;
3872    }
3873    if x >= 0.0 {
3874        f64_floor(x + 0.5)
3875    } else {
3876        f64_ceil(x - 0.5)
3877    }
3878}
3879
3880/// no_std-compatible `ceil(x)` for f64. Same shape as
3881/// `f64_floor` but rounds toward +infinity for fractional
3882/// values. Negative fractions round toward zero
3883/// (ceil(-1.5) → -1, NOT -2).
3884fn f64_ceil(x: f64) -> f64 {
3885    if x.is_nan() || x.is_infinite() {
3886        return x;
3887    }
3888    if x >= 9_007_199_254_740_992.0 || x <= -9_007_199_254_740_992.0 {
3889        return x;
3890    }
3891    let trunc = (x as i64) as f64;
3892    if x > 0.0 && x != trunc {
3893        trunc + 1.0
3894    } else {
3895        trunc
3896    }
3897}
3898
3899/// no_std-compatible `floor(x)` for f64. SPG's engine is
3900/// `#![no_std]` and can't call `f64::floor` directly (libm).
3901/// This handles the floor semantic manually:
3902///   * NaN / Inf passthrough.
3903///   * Values outside i64 range are already integer-precision.
3904///   * Negative non-integers floor toward -infinity (the
3905///     critical PG-canonical semantic).
3906fn f64_floor(x: f64) -> f64 {
3907    if x.is_nan() || x.is_infinite() {
3908        return x;
3909    }
3910    // f64 representation: any value with |x| > 2^53 is integer
3911    // precision (mantissa is 52 bits), so floor is identity.
3912    if x >= 9_007_199_254_740_992.0 || x <= -9_007_199_254_740_992.0 {
3913        return x;
3914    }
3915    let trunc = (x as i64) as f64;
3916    if x < 0.0 && x != trunc {
3917        trunc - 1.0
3918    } else {
3919        trunc
3920    }
3921}
3922
3923/// PG `lpad` / `rpad` shared implementation. Length is the
3924/// target codepoint count. When the input is longer than `length`,
3925/// truncate keeping the LEFT side (both lpad and rpad agree with
3926/// PG here). When shorter, pad with `fill` (default SPACE) cycling
3927/// for multi-char fills, on the appropriate side. Empty fill +
3928/// needs padding → returns input verbatim (potentially
3929/// truncated). NULL on any arg → NULL.
3930fn string_pad(args: &[Value], is_left: bool, fn_name: &str) -> Result<Value, EvalError> {
3931    if args.len() != 2 && args.len() != 3 {
3932        return Err(EvalError::TypeMismatch {
3933            detail: alloc::format!("{fn_name}() takes 2 or 3 args, got {}", args.len()),
3934        });
3935    }
3936    if args.iter().any(|v| matches!(v, Value::Null)) {
3937        return Ok(Value::Null);
3938    }
3939    let s = value_to_format_text(&args[0]);
3940    let target = match &args[1] {
3941        Value::SmallInt(x) => i64::from(*x),
3942        Value::Int(x) => i64::from(*x),
3943        Value::BigInt(x) => *x,
3944        other => {
3945            return Err(EvalError::TypeMismatch {
3946                detail: alloc::format!(
3947                    "{fn_name}(): length must be integer, got {:?}",
3948                    other.data_type()
3949                ),
3950            });
3951        }
3952    };
3953    let fill = if args.len() == 3 {
3954        value_to_format_text(&args[2])
3955    } else {
3956        String::from(" ")
3957    };
3958    if target <= 0 {
3959        return Ok(Value::Text(String::new()));
3960    }
3961    let target = target as usize;
3962    let s_chars: Vec<char> = s.chars().collect();
3963    if s_chars.len() >= target {
3964        // Truncate from the right (PG keeps LEFT side for both
3965        // lpad and rpad).
3966        return Ok(Value::Text(s_chars[..target].iter().collect()));
3967    }
3968    if fill.is_empty() {
3969        return Ok(Value::Text(s));
3970    }
3971    let pad_needed = target - s_chars.len();
3972    let fill_chars: Vec<char> = fill.chars().collect();
3973    let mut padding = String::with_capacity(pad_needed * 4);
3974    for i in 0..pad_needed {
3975        padding.push(fill_chars[i % fill_chars.len()]);
3976    }
3977    if is_left {
3978        Ok(Value::Text(padding + &s))
3979    } else {
3980        Ok(Value::Text(s + &padding))
3981    }
3982}
3983
3984/// PG `trim` / `ltrim` / `rtrim` / `btrim` shared implementation.
3985/// Accepts 1 or 2 args; coerces both to text via the standard
3986/// `value_to_format_text` helper; treats the chars arg as a SET
3987/// of UTF-8 codepoints (not a substring). NULL on either arg
3988/// poisons the result.
3989fn string_trim(args: &[Value], side: TrimSide, fn_name: &str) -> Result<Value, EvalError> {
3990    let (input, chars_str) = match args {
3991        [v] => (v.clone(), String::from(" ")),
3992        [v, c] => (v.clone(), {
3993            // NULL chars poisons.
3994            if matches!(c, Value::Null) {
3995                return Ok(Value::Null);
3996            }
3997            value_to_format_text(c)
3998        }),
3999        _ => {
4000            return Err(EvalError::TypeMismatch {
4001                detail: alloc::format!("{fn_name}() takes 1 or 2 args, got {}", args.len()),
4002            });
4003        }
4004    };
4005    if matches!(input, Value::Null) {
4006        return Ok(Value::Null);
4007    }
4008    let s = value_to_format_text(&input);
4009    let charset: alloc::collections::BTreeSet<char> = chars_str.chars().collect();
4010    let chars: Vec<char> = s.chars().collect();
4011    let mut start = 0usize;
4012    let mut end = chars.len();
4013    if matches!(side, TrimSide::Left | TrimSide::Both) {
4014        while start < end && charset.contains(&chars[start]) {
4015            start += 1;
4016        }
4017    }
4018    if matches!(side, TrimSide::Right | TrimSide::Both) {
4019        while end > start && charset.contains(&chars[end - 1]) {
4020            end -= 1;
4021        }
4022    }
4023    Ok(Value::Text(chars[start..end].iter().collect()))
4024}
4025
4026/// v7.17.0 Phase 3.8 — PG `format(fmtstr, args…)` with
4027/// sprintf-style conversion specifiers. Subset covered:
4028///   * `%s` — text rendering of the arg
4029///   * `%I` — quoted SQL identifier (always double-quoted; embedded
4030///     `"` doubled per SQL grammar)
4031///   * `%L` — quoted SQL literal (single-quoted; embedded `'`
4032///     doubled; NULL → literal `NULL`)
4033///   * `%%` — literal `%`
4034///   * `%n$X` — argument position (1-based) before the specifier
4035///     character (e.g. `%2$s` picks the 2nd arg)
4036fn format_string(args: &[Value]) -> Result<Value, EvalError> {
4037    if args.is_empty() {
4038        return Err(EvalError::TypeMismatch {
4039            detail: "format() takes at least 1 arg (format string)".into(),
4040        });
4041    }
4042    let fmt = match &args[0] {
4043        Value::Text(s) => s.clone(),
4044        Value::Null => return Ok(Value::Null),
4045        other => {
4046            return Err(EvalError::TypeMismatch {
4047                detail: format!(
4048                    "format(): first arg must be text, got {:?}",
4049                    other.data_type()
4050                ),
4051            });
4052        }
4053    };
4054    let arg_values = &args[1..];
4055    let mut out = String::new();
4056    let mut chars = fmt.chars().peekable();
4057    // Position cursor — next implicit arg picked when no `n$`
4058    // prefix is given. PG's format uses a 1-based cursor that
4059    // advances on each implicit-position spec.
4060    let mut implicit_cursor: usize = 0;
4061    while let Some(c) = chars.next() {
4062        if c != '%' {
4063            out.push(c);
4064            continue;
4065        }
4066        // Parse optional `n$` position prefix.
4067        let mut explicit_pos: Option<usize> = None;
4068        // Buffer the digits so we can roll back if no `$` follows.
4069        let mut digit_buf = String::new();
4070        while let Some(&d) = chars.peek() {
4071            if d.is_ascii_digit() {
4072                digit_buf.push(d);
4073                chars.next();
4074            } else {
4075                break;
4076            }
4077        }
4078        if !digit_buf.is_empty() && matches!(chars.peek(), Some(&'$')) {
4079            chars.next(); // consume `$`
4080            explicit_pos =
4081                Some(
4082                    digit_buf
4083                        .parse::<usize>()
4084                        .map_err(|_| EvalError::TypeMismatch {
4085                            detail: format!("format(): invalid arg position {digit_buf:?}"),
4086                        })?,
4087                );
4088            digit_buf.clear();
4089        }
4090        // Specifier character.
4091        let spec = match chars.next() {
4092            Some(c) => c,
4093            None => {
4094                return Err(EvalError::TypeMismatch {
4095                    detail: "format(): trailing `%` with no specifier".into(),
4096                });
4097            }
4098        };
4099        // Anything left in digit_buf (no `$`) was actually
4100        // pre-spec digits we now have to emit verbatim. PG would
4101        // treat them as width hint; v7.17 doesn't implement
4102        // width, but we don't want to silently drop the digits.
4103        // Strategy: ignore width for now and emit just the
4104        // converted value.
4105        let _ = digit_buf;
4106        if spec == '%' {
4107            out.push('%');
4108            continue;
4109        }
4110        let arg_index = match explicit_pos {
4111            Some(p) => p.saturating_sub(1),
4112            None => {
4113                let i = implicit_cursor;
4114                implicit_cursor += 1;
4115                i
4116            }
4117        };
4118        let arg = arg_values.get(arg_index).cloned().unwrap_or(Value::Null);
4119        match spec {
4120            's' => match arg {
4121                Value::Null => {} // PG: NULL renders as empty for %s.
4122                v => out.push_str(&value_to_format_text(&v)),
4123            },
4124            'I' => match arg {
4125                Value::Null => {
4126                    return Err(EvalError::TypeMismatch {
4127                        detail: "format(): NULL is not a valid identifier (%I)".into(),
4128                    });
4129                }
4130                v => {
4131                    let s = value_to_format_text(&v);
4132                    out.push('"');
4133                    for ch in s.chars() {
4134                        if ch == '"' {
4135                            out.push('"');
4136                            out.push('"');
4137                        } else {
4138                            out.push(ch);
4139                        }
4140                    }
4141                    out.push('"');
4142                }
4143            },
4144            'L' => match arg {
4145                Value::Null => out.push_str("NULL"),
4146                v => {
4147                    let s = value_to_format_text(&v);
4148                    out.push('\'');
4149                    for ch in s.chars() {
4150                        if ch == '\'' {
4151                            out.push('\'');
4152                            out.push('\'');
4153                        } else {
4154                            out.push(ch);
4155                        }
4156                    }
4157                    out.push('\'');
4158                }
4159            },
4160            other => {
4161                return Err(EvalError::TypeMismatch {
4162                    detail: format!(
4163                        "format(): unknown specifier '%{other}' \
4164                         (v7.17 supports %s %I %L %%)"
4165                    ),
4166                });
4167            }
4168        }
4169    }
4170    Ok(Value::Text(out))
4171}
4172
4173/// Helper: render a Value as text for format()'s %s / %I / %L
4174/// payload. Reuses the regular text-coercion table.
4175/// v7.17.0 Phase 3.P0-31 — map a `Value` to the canonical PG
4176/// type-name string returned by `pg_typeof`. Lowercase, matches
4177/// what real PostgreSQL emits (NOT SPG's UPPERCASE Display shape).
4178fn pg_typeof_name(v: &Value) -> &'static str {
4179    match v {
4180        Value::SmallInt(_) => "smallint",
4181        Value::Int(_) => "integer",
4182        Value::BigInt(_) => "bigint",
4183        Value::Float(_) => "double precision",
4184        Value::Text(_) => "text",
4185        Value::Bool(_) => "boolean",
4186        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => "vector",
4187        Value::Numeric { .. } => "numeric",
4188        Value::Date(_) => "date",
4189        Value::Timestamp(_) => "timestamp without time zone",
4190        Value::Interval { .. } => "interval",
4191        Value::Json(_) => {
4192            // SPG carries JSON and JSONB in the same Value::Json
4193            // variant; without a column ty hint we cannot tell
4194            // them apart at value level. Return "json" as the
4195            // conservative answer (PG's pg_typeof on a literal
4196            // `'{}'::json` returns "json"; the jsonb case is
4197            // covered when an explicit ::jsonb cast lands as
4198            // Value::Json too — see below override at call site).
4199            //
4200            // The eval-arm above for pg_typeof handles the
4201            // disambiguation via Expr-shape probing.
4202            "json"
4203        }
4204        Value::Bytes(_) => "bytea",
4205        Value::TextArray(_) => "text[]",
4206        Value::IntArray(_) => "integer[]",
4207        Value::BigIntArray(_) => "bigint[]",
4208        Value::TsVector(_) => "tsvector",
4209        Value::TsQuery(_) => "tsquery",
4210        Value::Uuid(_) => "uuid",
4211        Value::Null => "unknown",
4212        // Value is #[non_exhaustive]; future variants land here
4213        // until the table is updated.
4214        _ => "unknown",
4215    }
4216}
4217
4218fn value_to_format_text(v: &Value) -> String {
4219    match v {
4220        Value::Text(s) | Value::Json(s) => s.clone(),
4221        Value::SmallInt(n) => n.to_string(),
4222        Value::Int(n) => n.to_string(),
4223        Value::BigInt(n) => n.to_string(),
4224        Value::Float(x) => format!("{x}"),
4225        Value::Bool(b) => {
4226            if *b {
4227                "t".into()
4228            } else {
4229                "f".into()
4230            }
4231        }
4232        Value::Null => String::new(),
4233        other => format!("{other:?}"),
4234    }
4235}
4236
4237fn to_char(args: &[Value]) -> Result<Value, EvalError> {
4238    use core::fmt::Write as _;
4239    if args.len() != 2 {
4240        return Err(EvalError::TypeMismatch {
4241            detail: format!("to_char() takes 2 args, got {}", args.len()),
4242        });
4243    }
4244    if matches!(&args[0], Value::Null) || matches!(&args[1], Value::Null) {
4245        return Ok(Value::Null);
4246    }
4247    let Value::Text(fmt) = &args[1] else {
4248        return Err(EvalError::TypeMismatch {
4249            detail: format!(
4250                "to_char() needs a text format, got {:?}",
4251                args[1].data_type()
4252            ),
4253        });
4254    };
4255    let (days, day_micros) = match &args[0] {
4256        Value::Date(d) => (*d, 0_i64),
4257        Value::Timestamp(t) => {
4258            let days = t.div_euclid(86_400_000_000);
4259            (
4260                i32::try_from(days).unwrap_or(i32::MAX),
4261                t.rem_euclid(86_400_000_000),
4262            )
4263        }
4264        other => {
4265            return Err(EvalError::TypeMismatch {
4266                detail: format!(
4267                    "to_char() needs DATE or TIMESTAMP, got {:?}",
4268                    other.data_type()
4269                ),
4270            });
4271        }
4272    };
4273    let (y, mo, d) = civil_from_days(days);
4274    let secs = day_micros / 1_000_000;
4275    let frac = day_micros % 1_000_000;
4276    // div_euclid keeps every value non-negative — the casts below are
4277    // sign-safe by construction. `secs ∈ [0, 86400)`, `frac ∈ [0,
4278    // 1_000_000)`, so all three quantities fit in u32.
4279    let hh24 = u32::try_from(secs / 3600).unwrap_or(0);
4280    let mi = u32::try_from((secs / 60) % 60).unwrap_or(0);
4281    let ss = u32::try_from(secs % 60).unwrap_or(0);
4282    let hh12 = match hh24 % 12 {
4283        0 => 12,
4284        x => x,
4285    };
4286    let ampm = if hh24 < 12 { "AM" } else { "PM" };
4287    let ms = u32::try_from(frac / 1_000).unwrap_or(0); // millisecond
4288    let us = u32::try_from(frac).unwrap_or(0); // microsecond (0..1_000_000)
4289
4290    let mut out = String::with_capacity(fmt.len() + 8);
4291    let bytes = fmt.as_bytes();
4292    let mut i = 0;
4293    // write! against a String never fails — discard the Result.
4294    while i < bytes.len() {
4295        // Try the longest prefixes first so "YYYY" wins over "YY".
4296        let rest = &bytes[i..];
4297        if rest.starts_with(b"YYYY") {
4298            let _ = write!(out, "{y:04}");
4299            i += 4;
4300        } else if rest.starts_with(b"YY") {
4301            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
4302            let yy = (y.rem_euclid(100)) as u32;
4303            let _ = write!(out, "{yy:02}");
4304            i += 2;
4305        } else if rest.starts_with(b"Month") {
4306            out.push_str(MONTH_FULL[(mo - 1) as usize]);
4307            i += 5;
4308        } else if rest.starts_with(b"Mon") {
4309            out.push_str(MONTH_ABBR[(mo - 1) as usize]);
4310            i += 3;
4311        } else if rest.starts_with(b"MM") {
4312            let _ = write!(out, "{mo:02}");
4313            i += 2;
4314        } else if rest.starts_with(b"DD") {
4315            let _ = write!(out, "{d:02}");
4316            i += 2;
4317        } else if rest.starts_with(b"HH24") {
4318            let _ = write!(out, "{hh24:02}");
4319            i += 4;
4320        } else if rest.starts_with(b"HH12") {
4321            let _ = write!(out, "{hh12:02}");
4322            i += 4;
4323        } else if rest.starts_with(b"MI") {
4324            let _ = write!(out, "{mi:02}");
4325            i += 2;
4326        } else if rest.starts_with(b"SS") {
4327            let _ = write!(out, "{ss:02}");
4328            i += 2;
4329        } else if rest.starts_with(b"MS") {
4330            let _ = write!(out, "{ms:03}");
4331            i += 2;
4332        } else if rest.starts_with(b"US") {
4333            let _ = write!(out, "{us:06}");
4334            i += 2;
4335        } else if rest.starts_with(b"AM") || rest.starts_with(b"PM") {
4336            out.push_str(ampm);
4337            i += 2;
4338        } else {
4339            // Pass any non-placeholder byte through verbatim.
4340            out.push(bytes[i] as char);
4341            i += 1;
4342        }
4343    }
4344    Ok(Value::Text(out))
4345}
4346
4347const MONTH_FULL: [&str; 12] = [
4348    "January",
4349    "February",
4350    "March",
4351    "April",
4352    "May",
4353    "June",
4354    "July",
4355    "August",
4356    "September",
4357    "October",
4358    "November",
4359    "December",
4360];
4361const MONTH_ABBR: [&str; 12] = [
4362    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
4363];
4364
4365/// v7.17.0 Phase 3.P0-29 — MySQL `DATE_FORMAT(t, fmt)`.
4366///
4367/// Format tokens (MySQL 8.0 surface):
4368///   * `%Y` — 4-digit year  `%y` — 2-digit year
4369///   * `%m` — 01-12 month   `%c` — 1-12 month (no zero pad)
4370///   * `%d` — 01-31 day     `%e` — 1-31 day (no zero pad)
4371///   * `%H` — 00-23 hour    `%h` / `%I` — 01-12 hour
4372///   * `%i` — 00-59 MINUTE (NB: `%M` is month name in MySQL — easy
4373///     footgun if we mirror PG's `to_char` tokens by accident)
4374///   * `%s` / `%S` — 00-59 second
4375///   * `%f` — 000000-999999 microseconds (always 6 digits)
4376///   * `%p` — AM / PM
4377///   * `%M` — January-December (full month name)
4378///   * `%b` — Jan-Dec (abbreviated month name)
4379///   * `%%` — literal `%`
4380///
4381/// Unknown `%X` tokens pass through verbatim (MySQL emits the `%`
4382/// then the unknown letter).
4383fn date_format_mysql(args: &[Value]) -> Result<Value, EvalError> {
4384    use core::fmt::Write as _;
4385    if args.len() != 2 {
4386        return Err(EvalError::TypeMismatch {
4387            detail: format!("date_format() takes 2 args, got {}", args.len()),
4388        });
4389    }
4390    if matches!(&args[0], Value::Null) || matches!(&args[1], Value::Null) {
4391        return Ok(Value::Null);
4392    }
4393    let Value::Text(fmt) = &args[1] else {
4394        return Err(EvalError::TypeMismatch {
4395            detail: format!(
4396                "date_format() needs a text format, got {:?}",
4397                args[1].data_type()
4398            ),
4399        });
4400    };
4401    let (days, day_micros) = match &args[0] {
4402        Value::Date(d) => (*d, 0_i64),
4403        Value::Timestamp(t) => {
4404            let days = t.div_euclid(86_400_000_000);
4405            (
4406                i32::try_from(days).unwrap_or(i32::MAX),
4407                t.rem_euclid(86_400_000_000),
4408            )
4409        }
4410        other => {
4411            return Err(EvalError::TypeMismatch {
4412                detail: format!(
4413                    "date_format() needs DATE or TIMESTAMP, got {:?}",
4414                    other.data_type()
4415                ),
4416            });
4417        }
4418    };
4419    let (y, mo, d) = civil_from_days(days);
4420    let secs = day_micros / 1_000_000;
4421    let frac = day_micros % 1_000_000;
4422    let hh24 = u32::try_from(secs / 3600).unwrap_or(0);
4423    let mi = u32::try_from((secs / 60) % 60).unwrap_or(0);
4424    let ss = u32::try_from(secs % 60).unwrap_or(0);
4425    let hh12 = match hh24 % 12 {
4426        0 => 12,
4427        x => x,
4428    };
4429    let ampm = if hh24 < 12 { "AM" } else { "PM" };
4430    let us = u32::try_from(frac).unwrap_or(0);
4431
4432    let mut out = String::with_capacity(fmt.len() + 8);
4433    let bytes = fmt.as_bytes();
4434    let mut i = 0;
4435    while i < bytes.len() {
4436        if bytes[i] != b'%' {
4437            out.push(bytes[i] as char);
4438            i += 1;
4439            continue;
4440        }
4441        if i + 1 >= bytes.len() {
4442            // Trailing `%` with no specifier — emit verbatim.
4443            out.push('%');
4444            i += 1;
4445            continue;
4446        }
4447        let token = bytes[i + 1];
4448        match token {
4449            b'Y' => {
4450                let _ = write!(out, "{y:04}");
4451            }
4452            b'y' => {
4453                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
4454                let yy = (y.rem_euclid(100)) as u32;
4455                let _ = write!(out, "{yy:02}");
4456            }
4457            b'm' => {
4458                let _ = write!(out, "{mo:02}");
4459            }
4460            b'c' => {
4461                let _ = write!(out, "{mo}");
4462            }
4463            b'd' => {
4464                let _ = write!(out, "{d:02}");
4465            }
4466            b'e' => {
4467                let _ = write!(out, "{d}");
4468            }
4469            b'H' => {
4470                let _ = write!(out, "{hh24:02}");
4471            }
4472            b'h' | b'I' => {
4473                let _ = write!(out, "{hh12:02}");
4474            }
4475            b'i' => {
4476                // MINUTE — distinct from PG's `MI` and from MySQL's
4477                // own `%M` (month name).
4478                let _ = write!(out, "{mi:02}");
4479            }
4480            b's' | b'S' => {
4481                let _ = write!(out, "{ss:02}");
4482            }
4483            b'f' => {
4484                let _ = write!(out, "{us:06}");
4485            }
4486            b'p' => {
4487                out.push_str(ampm);
4488            }
4489            b'M' => {
4490                out.push_str(MONTH_FULL[(mo - 1) as usize]);
4491            }
4492            b'b' => {
4493                out.push_str(MONTH_ABBR[(mo - 1) as usize]);
4494            }
4495            b'%' => {
4496                out.push('%');
4497            }
4498            other => {
4499                // Unknown specifier — MySQL emits the letter
4500                // verbatim (without the `%`).
4501                out.push(other as char);
4502            }
4503        }
4504        i += 2;
4505    }
4506    Ok(Value::Text(out))
4507}
4508
4509/// v7.17.0 Phase 3.P0-29 — `UNIX_TIMESTAMP(t)` returns epoch
4510/// seconds (BIGINT) for a TIMESTAMP / DATE.
4511///
4512/// Bare `UNIX_TIMESTAMP()` (no args) is folded to a BigInt literal
4513/// by clock_replacement_for at the rewrite layer — never reaches
4514/// this arm.
4515fn unix_timestamp_of(args: &[Value]) -> Result<Value, EvalError> {
4516    if args.len() != 1 {
4517        return Err(EvalError::TypeMismatch {
4518            detail: format!("unix_timestamp() takes 0 or 1 arg, got {}", args.len()),
4519        });
4520    }
4521    match &args[0] {
4522        Value::Null => Ok(Value::Null),
4523        Value::Timestamp(t) => Ok(Value::BigInt(t.div_euclid(1_000_000))),
4524        Value::Date(d) => Ok(Value::BigInt(i64::from(*d) * 86_400)),
4525        other => Err(EvalError::TypeMismatch {
4526            detail: format!(
4527                "unix_timestamp() needs DATE or TIMESTAMP, got {:?}",
4528                other.data_type()
4529            ),
4530        }),
4531    }
4532}
4533
4534/// v7.17.0 Phase 3.P0-29 — `FROM_UNIXTIME(n)` returns a TIMESTAMP
4535/// at `n` seconds past the Unix epoch. `FROM_UNIXTIME(n, fmt)`
4536/// applies MySQL date_format on top, returning TEXT.
4537fn from_unixtime(args: &[Value]) -> Result<Value, EvalError> {
4538    if !(1..=2).contains(&args.len()) {
4539        return Err(EvalError::TypeMismatch {
4540            detail: format!("from_unixtime() takes 1 or 2 args, got {}", args.len()),
4541        });
4542    }
4543    if args.iter().any(|v| matches!(v, Value::Null)) {
4544        return Ok(Value::Null);
4545    }
4546    let secs: i64 = match &args[0] {
4547        Value::SmallInt(n) => i64::from(*n),
4548        Value::Int(n) => i64::from(*n),
4549        Value::BigInt(n) => *n,
4550        Value::Float(x) => *x as i64,
4551        Value::Numeric { scaled, scale } => {
4552            let denom = 10_i128.pow(u32::from(*scale));
4553            i64::try_from(scaled.div_euclid(denom)).unwrap_or(i64::MAX)
4554        }
4555        other => {
4556            return Err(EvalError::TypeMismatch {
4557                detail: format!(
4558                    "from_unixtime() needs a numeric epoch second count, got {:?}",
4559                    other.data_type()
4560                ),
4561            });
4562        }
4563    };
4564    let ts = Value::Timestamp(secs.saturating_mul(1_000_000));
4565    if args.len() == 1 {
4566        Ok(ts)
4567    } else {
4568        date_format_mysql(&[ts, args[1].clone()])
4569    }
4570}
4571
4572/// `date_trunc(unit, timestamp)` — round a `TIMESTAMP` down to the
4573/// requested calendar boundary (year / month / day / hour / minute /
4574/// second). Returns the truncated `TIMESTAMP`. NULL on either side
4575/// propagates to NULL.
4576fn date_trunc(args: &[Value]) -> Result<Value, EvalError> {
4577    if args.len() != 2 {
4578        return Err(EvalError::TypeMismatch {
4579            detail: format!("date_trunc() takes 2 args, got {}", args.len()),
4580        });
4581    }
4582    if matches!(&args[0], Value::Null) || matches!(&args[1], Value::Null) {
4583        return Ok(Value::Null);
4584    }
4585    let Value::Text(unit) = &args[0] else {
4586        return Err(EvalError::TypeMismatch {
4587            detail: format!(
4588                "date_trunc() needs a text unit, got {:?}",
4589                args[0].data_type()
4590            ),
4591        });
4592    };
4593    // Both DATE and TIMESTAMP sources are accepted. DATE lifts to
4594    // midnight first; the result is always TIMESTAMP.
4595    let micros = match &args[1] {
4596        Value::Timestamp(t) => *t,
4597        Value::Date(d) => i64::from(*d) * 86_400_000_000,
4598        other => {
4599            return Err(EvalError::TypeMismatch {
4600                detail: format!(
4601                    "date_trunc() needs DATE or TIMESTAMP, got {:?}",
4602                    other.data_type()
4603                ),
4604            });
4605        }
4606    };
4607    let unit_lc = unit.to_ascii_lowercase();
4608    let days = micros.div_euclid(86_400_000_000);
4609    let day_micros = micros.rem_euclid(86_400_000_000);
4610    let day_i32 = i32::try_from(days).unwrap_or(i32::MAX);
4611    let (y, m, _) = civil_from_days(day_i32);
4612    let truncated = match unit_lc.as_str() {
4613        "year" => i64::from(days_from_civil(y, 1, 1)) * 86_400_000_000,
4614        "month" => i64::from(days_from_civil(y, m, 1)) * 86_400_000_000,
4615        "day" => days * 86_400_000_000,
4616        "hour" => days * 86_400_000_000 + (day_micros / 3_600_000_000) * 3_600_000_000,
4617        "minute" => days * 86_400_000_000 + (day_micros / 60_000_000) * 60_000_000,
4618        "second" => days * 86_400_000_000 + (day_micros / 1_000_000) * 1_000_000,
4619        other => {
4620            return Err(EvalError::TypeMismatch {
4621                detail: format!(
4622                    "unknown date_trunc unit {other:?}; \
4623                     supported: year, month, day, hour, minute, second"
4624                ),
4625            });
4626        }
4627    };
4628    Ok(Value::Timestamp(truncated))
4629}
4630
4631/// PG-style `expr::TYPE` coercion. NULL always casts as NULL.
4632pub fn cast_value(v: Value, target: CastTarget) -> Result<Value, EvalError> {
4633    if matches!(v, Value::Null) {
4634        return Ok(Value::Null);
4635    }
4636    match target {
4637        CastTarget::Vector => cast_to_vector(v),
4638        CastTarget::Text => Ok(Value::Text(value_to_text(&v))),
4639        CastTarget::Int => cast_numeric_to_int(v),
4640        CastTarget::BigInt => cast_numeric_to_bigint(v),
4641        CastTarget::Float => cast_numeric_to_float(v),
4642        CastTarget::Bool => cast_to_bool(v),
4643        CastTarget::Date => cast_to_date(v),
4644        // TIMESTAMP and TIMESTAMPTZ have identical runtime
4645        // representation (i64 microseconds UTC).
4646        CastTarget::Timestamp | CastTarget::Timestamptz => cast_to_timestamp(v),
4647        // v7.9.25 — `expr::INTERVAL`. Currently only TEXT → Interval
4648        // is supported (the mailrs idiom: `$1::INTERVAL` where the
4649        // bound param is a string like `'7 days'`).
4650        CastTarget::Interval => cast_to_interval(v),
4651        // v7.9.25 — `::json` / `::jsonb`. Routes Text → Json
4652        // (validation is the producer's responsibility, same as
4653        // the column-INSERT path).
4654        CastTarget::Json | CastTarget::Jsonb => match v {
4655            Value::Json(s) => Ok(Value::Json(s)),
4656            Value::Text(s) => Ok(Value::Json(s)),
4657            other => Err(EvalError::TypeMismatch {
4658                detail: alloc::format!(
4659                    "::json / ::jsonb only accepts TEXT-shape inputs, got {:?}",
4660                    other.data_type()
4661                ),
4662            }),
4663        },
4664        // v7.17.0 Phase 5.3 — `::regtype` / `::regclass`. PG
4665        // semantics: each is a textual catalog-name surfacing as
4666        // a numeric OID at the wire layer that renders back as
4667        // the original name. SPG has no OID space, but pg_dump /
4668        // mailrs / Django code uses the cast purely for textual
4669        // round-trip — feeding `'public.t'::regclass::text` into
4670        // a downstream `format(…)` or string concat. We map to
4671        // that textual contract: Text in → Text out (the schema-
4672        // qualifier `public.` is stripped to match PG's default
4673        // search_path-aware rendering); numeric in → re-cast to
4674        // Text as best-effort; anything else errors.
4675        //
4676        // Pre-3.3 / pre-5.3 (v7.9.26) the cast surfaced a clean
4677        // error; this lifts to accept-and-textify so the dominant
4678        // dump-loader pattern unblocks. SPG-shaped queries that
4679        // genuinely need an OID for runtime joins are still
4680        // documented as unsupported.
4681        CastTarget::RegType | CastTarget::RegClass => match v {
4682            Value::Text(s) => {
4683                // Strip an optional `<schema>.` prefix — PG's
4684                // regclass render drops it when the schema is on
4685                // the search_path; SPG is single-schema so
4686                // dropping is always safe.
4687                let bare = s.rsplit('.').next().unwrap_or(&s).to_string();
4688                Ok(Value::Text(bare))
4689            }
4690            Value::Int(n) => Ok(Value::Text(alloc::format!("{n}"))),
4691            Value::BigInt(n) => Ok(Value::Text(alloc::format!("{n}"))),
4692            other => Err(EvalError::TypeMismatch {
4693                detail: alloc::format!(
4694                    "::regtype / ::regclass accepts TEXT (name) or integer (oid), got {:?}",
4695                    other.data_type()
4696                ),
4697            }),
4698        },
4699        // v7.10.11 — `::TEXT[]`. Decode PG external array form
4700        // when input is Text; pass through unchanged when it is
4701        // already TextArray. Anything else is a type mismatch.
4702        CastTarget::TextArray => match v {
4703            Value::TextArray(items) => Ok(Value::TextArray(items)),
4704            Value::Text(s) => decode_text_array_external(&s).map(Value::TextArray),
4705            other => Err(EvalError::TypeMismatch {
4706                detail: alloc::format!(
4707                    "::TEXT[] only accepts TEXT / TEXT[] inputs, got {:?}",
4708                    other.data_type()
4709                ),
4710            }),
4711        },
4712        // v7.11.13 — `::INT[]` / `::BIGINT[]`. Decode PG external
4713        // form `{1,2,3}` when input is Text; widen TextArray /
4714        // IntArray as appropriate.
4715        CastTarget::IntArray => cast_to_int_array(v),
4716        CastTarget::BigIntArray => cast_to_bigint_array(v),
4717        // v7.12.0 — `::tsvector` / `::tsquery`. Decodes PG external
4718        // form when input is Text; passes through unchanged when the
4719        // input is already the target type. Other inputs are a type
4720        // mismatch. Lexer / Porter stemmer arrive in v7.12.1; the
4721        // external-form cast at v7.12.0 is the path pg_dump and
4722        // direct-literal callers use.
4723        CastTarget::TsVector => match v {
4724            Value::TsVector(items) => Ok(Value::TsVector(items)),
4725            Value::Text(s) => decode_tsvector_external(&s).map(Value::TsVector),
4726            other => Err(EvalError::TypeMismatch {
4727                detail: alloc::format!(
4728                    "::tsvector only accepts TEXT / tsvector inputs, got {:?}",
4729                    other.data_type()
4730                ),
4731            }),
4732        },
4733        CastTarget::TsQuery => match v {
4734            Value::TsQuery(ast) => Ok(Value::TsQuery(ast)),
4735            Value::Text(s) => decode_tsquery_external(&s).map(Value::TsQuery),
4736            other => Err(EvalError::TypeMismatch {
4737                detail: alloc::format!(
4738                    "::tsquery only accepts TEXT / tsquery inputs, got {:?}",
4739                    other.data_type()
4740                ),
4741            }),
4742        },
4743        // v7.17.0 — `::uuid`. Identity for `uuid → uuid`; parse
4744        // text via the shared `parse_uuid_str`. Anything else is a
4745        // type mismatch — PG also rejects e.g. INT → UUID without
4746        // an explicit text bridge.
4747        CastTarget::Uuid => match v {
4748            Value::Uuid(b) => Ok(Value::Uuid(b)),
4749            Value::Text(s) => match spg_storage::parse_uuid_str(&s) {
4750                Some(b) => Ok(Value::Uuid(b)),
4751                None => Err(EvalError::TypeMismatch {
4752                    detail: alloc::format!("invalid input syntax for type uuid: {s:?}"),
4753                }),
4754            },
4755            other => Err(EvalError::TypeMismatch {
4756                detail: alloc::format!(
4757                    "::uuid only accepts TEXT / uuid inputs, got {:?}",
4758                    other.data_type()
4759                ),
4760            }),
4761        },
4762        // v7.18 — `::bytea`. Identity for `Bytes → Bytes`; decode
4763        // Text via the engine's PG-format bytea decoder (`\x`
4764        // hex form + `\NNN` escape form). Anything else is a type
4765        // mismatch — same shape as PG's contract. Closes the
4766        // mailrs D-pre #3 reverse-acceptance gap.
4767        CastTarget::Bytea => match v {
4768            Value::Bytes(b) => Ok(Value::Bytes(b)),
4769            Value::Text(s) => match crate::decode_bytea_literal(&s) {
4770                Ok(b) => Ok(Value::Bytes(b)),
4771                Err(msg) => Err(EvalError::TypeMismatch {
4772                    detail: alloc::format!("invalid input syntax for type bytea: {msg}"),
4773                }),
4774            },
4775            other => Err(EvalError::TypeMismatch {
4776                detail: alloc::format!(
4777                    "::bytea only accepts TEXT / bytea inputs, got {:?}",
4778                    other.data_type()
4779                ),
4780            }),
4781        },
4782    }
4783}
4784
4785fn cast_to_int_array(v: Value) -> Result<Value, EvalError> {
4786    match v {
4787        Value::IntArray(items) => Ok(Value::IntArray(items)),
4788        Value::BigIntArray(items) => {
4789            let mut out: Vec<Option<i32>> = Vec::with_capacity(items.len());
4790            for item in items {
4791                match item {
4792                    None => out.push(None),
4793                    Some(n) => match i32::try_from(n) {
4794                        Ok(x) => out.push(Some(x)),
4795                        Err(_) => {
4796                            return Err(EvalError::TypeMismatch {
4797                                detail: alloc::format!("::INT[] element {n} overflows i32"),
4798                            });
4799                        }
4800                    },
4801                }
4802            }
4803            Ok(Value::IntArray(out))
4804        }
4805        Value::Text(s) => decode_int_array_external(&s).map(Value::IntArray),
4806        Value::TextArray(items) => {
4807            let mut out: Vec<Option<i32>> = Vec::with_capacity(items.len());
4808            for item in items {
4809                match item {
4810                    None => out.push(None),
4811                    Some(s) => match s.parse::<i32>() {
4812                        Ok(n) => out.push(Some(n)),
4813                        Err(_) => {
4814                            return Err(EvalError::TypeMismatch {
4815                                detail: alloc::format!("::INT[] cannot parse {s:?}"),
4816                            });
4817                        }
4818                    },
4819                }
4820            }
4821            Ok(Value::IntArray(out))
4822        }
4823        other => Err(EvalError::TypeMismatch {
4824            detail: alloc::format!("::INT[] does not accept {:?}", other.data_type()),
4825        }),
4826    }
4827}
4828
4829fn cast_to_bigint_array(v: Value) -> Result<Value, EvalError> {
4830    match v {
4831        Value::BigIntArray(items) => Ok(Value::BigIntArray(items)),
4832        Value::IntArray(items) => Ok(Value::BigIntArray(
4833            items.into_iter().map(|x| x.map(i64::from)).collect(),
4834        )),
4835        Value::Text(s) => decode_bigint_array_external(&s).map(Value::BigIntArray),
4836        Value::TextArray(items) => {
4837            let mut out: Vec<Option<i64>> = Vec::with_capacity(items.len());
4838            for item in items {
4839                match item {
4840                    None => out.push(None),
4841                    Some(s) => match s.parse::<i64>() {
4842                        Ok(n) => out.push(Some(n)),
4843                        Err(_) => {
4844                            return Err(EvalError::TypeMismatch {
4845                                detail: alloc::format!("::BIGINT[] cannot parse {s:?}"),
4846                            });
4847                        }
4848                    },
4849                }
4850            }
4851            Ok(Value::BigIntArray(out))
4852        }
4853        other => Err(EvalError::TypeMismatch {
4854            detail: alloc::format!("::BIGINT[] does not accept {:?}", other.data_type()),
4855        }),
4856    }
4857}
4858
4859fn decode_int_array_external(s: &str) -> Result<Vec<Option<i32>>, EvalError> {
4860    let trimmed = s.trim();
4861    let inner = trimmed
4862        .strip_prefix('{')
4863        .and_then(|x| x.strip_suffix('}'))
4864        .ok_or_else(|| EvalError::TypeMismatch {
4865            detail: alloc::format!("INT[] literal {s:?} must be enclosed in '{{...}}'"),
4866        })?;
4867    if inner.trim().is_empty() {
4868        return Ok(Vec::new());
4869    }
4870    inner
4871        .split(',')
4872        .map(|part| {
4873            let p = part.trim();
4874            if p.eq_ignore_ascii_case("NULL") {
4875                Ok(None)
4876            } else {
4877                p.parse::<i32>()
4878                    .map(Some)
4879                    .map_err(|_| EvalError::TypeMismatch {
4880                        detail: alloc::format!("INT[] element {p:?} is not an i32"),
4881                    })
4882            }
4883        })
4884        .collect()
4885}
4886
4887fn decode_bigint_array_external(s: &str) -> Result<Vec<Option<i64>>, EvalError> {
4888    let trimmed = s.trim();
4889    let inner = trimmed
4890        .strip_prefix('{')
4891        .and_then(|x| x.strip_suffix('}'))
4892        .ok_or_else(|| EvalError::TypeMismatch {
4893            detail: alloc::format!("BIGINT[] literal {s:?} must be enclosed in '{{...}}'"),
4894        })?;
4895    if inner.trim().is_empty() {
4896        return Ok(Vec::new());
4897    }
4898    inner
4899        .split(',')
4900        .map(|part| {
4901            let p = part.trim();
4902            if p.eq_ignore_ascii_case("NULL") {
4903                Ok(None)
4904            } else {
4905                p.parse::<i64>()
4906                    .map(Some)
4907                    .map_err(|_| EvalError::TypeMismatch {
4908                        detail: alloc::format!("BIGINT[] element {p:?} is not an i64"),
4909                    })
4910            }
4911        })
4912        .collect()
4913}
4914
4915/// v7.10.11 — same decoder as `decode_text_array_literal` in
4916/// `lib.rs`, but lives here so the eval-time cast path stays
4917/// inside `spg-engine::eval`. Kept in lock-step with the engine
4918/// `coerce_value` decoder by tests.
4919fn decode_text_array_external(s: &str) -> Result<Vec<Option<String>>, EvalError> {
4920    let trimmed = s.trim();
4921    let inner = trimmed
4922        .strip_prefix('{')
4923        .and_then(|x| x.strip_suffix('}'))
4924        .ok_or_else(|| EvalError::TypeMismatch {
4925            detail: alloc::format!("TEXT[] literal {s:?} must be enclosed in '{{...}}'"),
4926        })?;
4927    let mut out: Vec<Option<String>> = Vec::new();
4928    if inner.trim().is_empty() {
4929        return Ok(out);
4930    }
4931    let bytes = inner.as_bytes();
4932    let mut i = 0;
4933    while i <= bytes.len() {
4934        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
4935            i += 1;
4936        }
4937        if i < bytes.len() && bytes[i] == b'"' {
4938            i += 1;
4939            let mut buf = String::new();
4940            while i < bytes.len() && bytes[i] != b'"' {
4941                if bytes[i] == b'\\' && i + 1 < bytes.len() {
4942                    buf.push(bytes[i + 1] as char);
4943                    i += 2;
4944                } else {
4945                    buf.push(bytes[i] as char);
4946                    i += 1;
4947                }
4948            }
4949            if i >= bytes.len() {
4950                return Err(EvalError::TypeMismatch {
4951                    detail: "unterminated quoted element in TEXT[] literal".into(),
4952                });
4953            }
4954            i += 1;
4955            out.push(Some(buf));
4956        } else {
4957            let start = i;
4958            while i < bytes.len() && bytes[i] != b',' {
4959                i += 1;
4960            }
4961            let raw = inner[start..i].trim();
4962            if raw.eq_ignore_ascii_case("NULL") {
4963                out.push(None);
4964            } else {
4965                out.push(Some(raw.to_string()));
4966            }
4967        }
4968        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
4969            i += 1;
4970        }
4971        if i >= bytes.len() {
4972            break;
4973        }
4974        if bytes[i] != b',' {
4975            return Err(EvalError::TypeMismatch {
4976                detail: "expected ',' between TEXT[] elements".into(),
4977            });
4978        }
4979        i += 1;
4980    }
4981    Ok(out)
4982}
4983
4984fn cast_to_interval(v: Value) -> Result<Value, EvalError> {
4985    match v {
4986        Value::Interval { months, micros } => Ok(Value::Interval { months, micros }),
4987        Value::Text(s) => {
4988            let (months, micros) = spg_sql::parser::parse_interval_text(&s).ok_or_else(|| {
4989                EvalError::TypeMismatch {
4990                    detail: alloc::format!("cannot parse {s:?} as INTERVAL"),
4991                }
4992            })?;
4993            Ok(Value::Interval { months, micros })
4994        }
4995        other => Err(EvalError::TypeMismatch {
4996            detail: alloc::format!(
4997                "::INTERVAL only accepts TEXT-shape inputs, got {:?}",
4998                other.data_type()
4999            ),
5000        }),
5001    }
5002}
5003
5004fn cast_to_date(v: Value) -> Result<Value, EvalError> {
5005    match v {
5006        Value::Date(d) => Ok(Value::Date(d)),
5007        // Integer literals carry days since the Unix epoch — used by
5008        // the `CURRENT_DATE` AST rewrite to inject the wall clock.
5009        Value::Int(n) => Ok(Value::Date(n)),
5010        Value::BigInt(n) => {
5011            i32::try_from(n)
5012                .map(Value::Date)
5013                .map_err(|_| EvalError::TypeMismatch {
5014                    detail: "bigint days-since-epoch out of DATE range".into(),
5015                })
5016        }
5017        // Timestamp truncates to its day boundary.
5018        Value::Timestamp(t) => {
5019            let days = t.div_euclid(86_400_000_000);
5020            i32::try_from(days)
5021                .map(Value::Date)
5022                .map_err(|_| EvalError::TypeMismatch {
5023                    detail: "timestamp out of DATE range".into(),
5024                })
5025        }
5026        Value::Text(s) => parse_date_literal(&s)
5027            .map(Value::Date)
5028            .ok_or(EvalError::TypeMismatch {
5029                detail: format!("cannot parse {s:?} as DATE (expected YYYY-MM-DD)"),
5030            }),
5031        other => Err(EvalError::TypeMismatch {
5032            detail: format!("cannot cast {:?} to DATE", other.data_type()),
5033        }),
5034    }
5035}
5036
5037fn cast_to_timestamp(v: Value) -> Result<Value, EvalError> {
5038    match v {
5039        Value::Timestamp(t) => Ok(Value::Timestamp(t)),
5040        // Int / BigInt carry microseconds since the Unix epoch — used
5041        // by the `NOW()` / `CURRENT_TIMESTAMP` AST rewrite to inject
5042        // the wall clock as a plain integer literal.
5043        Value::Int(n) => Ok(Value::Timestamp(i64::from(n))),
5044        Value::BigInt(n) => Ok(Value::Timestamp(n)),
5045        // DATE → TIMESTAMP picks midnight on the date.
5046        Value::Date(d) => Ok(Value::Timestamp(i64::from(d) * 86_400_000_000)),
5047        Value::Text(s) => {
5048            parse_timestamp_literal(&s)
5049                .map(Value::Timestamp)
5050                .ok_or(EvalError::TypeMismatch {
5051                    detail: format!(
5052                        "cannot parse {s:?} as TIMESTAMP \
5053                     (expected YYYY-MM-DD[ HH:MM:SS[.ffffff]])"
5054                    ),
5055                })
5056        }
5057        other => Err(EvalError::TypeMismatch {
5058            detail: format!("cannot cast {:?} to TIMESTAMP", other.data_type()),
5059        }),
5060    }
5061}
5062
5063fn value_to_text(v: &Value) -> String {
5064    match v {
5065        // v7.5.0 — Value is #[non_exhaustive]; any future variant
5066        // without explicit text rendering hits the Debug fallback
5067        // at the end.
5068        Value::SmallInt(n) => format!("{n}"),
5069        Value::Int(n) => format!("{n}"),
5070        Value::BigInt(n) => format!("{n}"),
5071        Value::Float(x) => format!("{x}"),
5072        // v4.9: JSON renders identically to Text — both are raw UTF-8.
5073        Value::Text(s) | Value::Json(s) => s.clone(),
5074        Value::Bool(b) => (if *b { "true" } else { "false" }).into(),
5075        Value::Vector(v) => {
5076            let cells: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
5077            format!("[{}]", cells.join(", "))
5078        }
5079        // v6.0.1: render SQ8 cells dequantised, so SELECT output
5080        // matches the pgvector wire shape clients expect. The
5081        // recall envelope already absorbs the ≤ (max-min)/255/2
5082        // dequantisation error.
5083        Value::Sq8Vector(q) => {
5084            let cells: Vec<String> = spg_storage::quantize::dequantize(q)
5085                .iter()
5086                .map(|x| format!("{x}"))
5087                .collect();
5088            format!("[{}]", cells.join(", "))
5089        }
5090        // v6.0.3: HalfVector cells dequantise bit-exactly to f32
5091        // for SELECT output.
5092        Value::HalfVector(h) => {
5093            let cells: Vec<String> = h.to_f32_vec().iter().map(|x| format!("{x}")).collect();
5094            format!("[{}]", cells.join(", "))
5095        }
5096        Value::Numeric { scaled, scale } => format_numeric(*scaled, *scale),
5097        Value::Date(d) => format_date(*d),
5098        Value::Timestamp(t) => format_timestamp(*t),
5099        Value::Interval { months, micros } => format_interval(*months, *micros),
5100        Value::Null => "NULL".into(),
5101        // v7.10.4 — BYTEA renders as PG hex form.
5102        Value::Bytes(b) => format_bytea_hex(b),
5103        // v7.10.9 — TEXT[] / INT[] / BIGINT[] render PG external form.
5104        Value::TextArray(items) => format_text_array(items),
5105        Value::IntArray(items) => format_int_array(items),
5106        Value::BigIntArray(items) => format_bigint_array(items),
5107        // v7.12.0 — tsvector / tsquery render PG external form.
5108        Value::TsVector(lexs) => format_tsvector(lexs),
5109        Value::TsQuery(ast) => format_tsquery(ast),
5110        // v7.17.0 — UUID renders canonical lowercase 8-4-4-4-12
5111        // hyphenated form (PG `uuid_out`).
5112        Value::Uuid(b) => spg_storage::format_uuid(b),
5113        // v7.17.0 Phase 3.P0-32 — TIME canonical text.
5114        Value::Time(us) => format_time(*us),
5115        // v7.17.0 Phase 3.P0-34 — TIMETZ canonical text.
5116        Value::TimeTz { us, offset_secs } => format_timetz(*us, *offset_secs),
5117        // v7.17.0 Phase 3.P0-33 — YEAR 4-digit zero-padded.
5118        Value::Year(y) => format!("{y:04}"),
5119        // v7.17.0 Phase 3.P0-35 — MONEY en_US locale.
5120        Value::Money(c) => format_money(*c),
5121        // v7.17.0 Phase 3.P0-38 — Range canonical form. Routes
5122        // through the engine's format_range_text to share the
5123        // single renderer with pgwire / sqllogictest.
5124        Value::Range { .. } => crate::format_range_text(v),
5125        // v7.17.0 Phase 3.P0-39 — Hstore canonical PG text form.
5126        Value::Hstore(pairs) => crate::format_hstore_text(pairs),
5127        // v7.17.0 Phase 3.P0-40 — 2D array canonical PG text form.
5128        Value::IntArray2D(rows) => crate::format_int_2d_text_pub(rows),
5129        Value::BigIntArray2D(rows) => crate::format_bigint_2d_text_pub(rows),
5130        Value::TextArray2D(rows) => crate::format_text_2d_text_pub(rows),
5131        // v7.5.0 — #[non_exhaustive] fallback for future Value variants.
5132        _ => format!("{v:?}"),
5133    }
5134}
5135
5136/// Render a `Date` (days since epoch) as `YYYY-MM-DD`. Negative values
5137/// for pre-1970 dates render with a leading `-` on the year.
5138pub fn format_date(days: i32) -> String {
5139    let (y, m, d) = civil_from_days(days);
5140    format!("{y:04}-{m:02}-{d:02}")
5141}
5142
5143/// Render a `Timestamp` (microseconds since epoch) as
5144/// `YYYY-MM-DD HH:MM:SS[.fff...]`. Trailing-zero fractional digits are
5145/// dropped; a whole-second value has no fractional part.
5146/// v7.15.0 — PG-canonical TIMESTAMPTZ wire format. Storage is
5147/// the same i64 microseconds UTC as TIMESTAMP, but the canonical
5148/// PG text output appends the session's UTC-offset suffix (`+00`
5149/// for the default UTC session, the form pg_dump emits). Mailrs
5150/// round-8 acceptance criterion: `SELECT col FROM tstz` should
5151/// round-trip to a literal that re-INSERTs without semantic
5152/// drift.
5153pub fn format_timestamptz(micros: i64) -> String {
5154    let base = format_timestamp(micros);
5155    let mut s = String::with_capacity(base.len() + 3);
5156    s.push_str(&base);
5157    s.push_str("+00");
5158    s
5159}
5160
5161/// v7.17.0 Phase 3.P0-35 — PG `money` canonical text form, en_US
5162/// locale: `$N,NNN.CC`, negative → `-$1.23`. Mirrors PG's
5163/// `cash_out` for `lc_monetary = 'en_US.UTF-8'`.
5164pub fn format_money(cents: i64) -> String {
5165    let neg = cents < 0;
5166    let abs = cents.unsigned_abs();
5167    let dollars = abs / 100;
5168    let cc = abs % 100;
5169    // Insert comma thousands separators in the integer portion.
5170    let dollar_str = dollars.to_string();
5171    let bytes = dollar_str.as_bytes();
5172    let mut int_part = String::with_capacity(dollar_str.len() + dollar_str.len() / 3);
5173    for (i, b) in bytes.iter().enumerate() {
5174        // Position from the right: insert ',' before every 3rd
5175        // digit (except the first).
5176        let from_right = bytes.len() - i;
5177        if i > 0 && from_right % 3 == 0 {
5178            int_part.push(',');
5179        }
5180        int_part.push(*b as char);
5181    }
5182    let sign = if neg { "-" } else { "" };
5183    format!("{sign}${int_part}.{cc:02}")
5184}
5185
5186/// v7.17.0 Phase 3.P0-34 — PG `TIMETZ` canonical text form
5187/// `HH:MM:SS[.ffffff]±HH[:MM]`. Mirrors PG `timetz_out`. The
5188/// offset uses `±HH` for whole-hour offsets and `±HH:MM` for
5189/// sub-hour offsets (matching PG's "minimal display" rule).
5190pub fn format_timetz(us: i64, offset_secs: i32) -> String {
5191    let time = format_time(us);
5192    let sign = if offset_secs < 0 { '-' } else { '+' };
5193    let abs = offset_secs.unsigned_abs();
5194    let oh = abs / 3600;
5195    let om = (abs % 3600) / 60;
5196    if om == 0 {
5197        format!("{time}{sign}{oh:02}")
5198    } else {
5199        format!("{time}{sign}{oh:02}:{om:02}")
5200    }
5201}
5202
5203/// v7.17.0 Phase 3.P0-32 — PG `TIME` canonical text form
5204/// `HH:MM:SS[.ffffff]`. Mirrors PG `time_out`. Trailing zeros in
5205/// the fractional component are stripped — `12:00:00.500000`
5206/// renders as `12:00:00.5` to match PG's text output.
5207pub fn format_time(us: i64) -> String {
5208    let total_secs = us.div_euclid(1_000_000);
5209    let frac = us.rem_euclid(1_000_000);
5210    let hh = total_secs / 3600;
5211    let mm = (total_secs / 60) % 60;
5212    let ss = total_secs % 60;
5213    if frac == 0 {
5214        format!("{hh:02}:{mm:02}:{ss:02}")
5215    } else {
5216        let raw = format!("{frac:06}");
5217        let trimmed = raw.trim_end_matches('0');
5218        format!("{hh:02}:{mm:02}:{ss:02}.{trimmed}")
5219    }
5220}
5221
5222pub fn format_timestamp(micros: i64) -> String {
5223    const MICROS_PER_DAY: i64 = 86_400_000_000;
5224    // Split into day + intra-day part with proper floor division so
5225    // negative timestamps render right too.
5226    let days = micros.div_euclid(MICROS_PER_DAY);
5227    let day_micros = micros.rem_euclid(MICROS_PER_DAY);
5228    let day_i32 = i32::try_from(days).unwrap_or(i32::MAX);
5229    let (y, m, d) = civil_from_days(day_i32);
5230    let secs = day_micros / 1_000_000;
5231    let frac = day_micros % 1_000_000;
5232    let hh = secs / 3600;
5233    let mm = (secs / 60) % 60;
5234    let ss = secs % 60;
5235    if frac == 0 {
5236        format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02}")
5237    } else {
5238        // Strip trailing zeros from the 6-digit fractional component.
5239        let raw = format!("{frac:06}");
5240        let trimmed = raw.trim_end_matches('0');
5241        format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02}.{trimmed}")
5242    }
5243}
5244
5245/// Howard Hinnant's `civil_from_days` — converts days since the Unix
5246/// epoch back to a proleptic-Gregorian (year, month, day) triple. Both
5247/// directions of this calendar conversion live in `eval.rs` so the
5248/// engine never reaches for `std` time facilities.
5249#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5250fn civil_from_days(days: i32) -> (i32, u32, u32) {
5251    let z = i64::from(days) + 719_468;
5252    let era = z.div_euclid(146_097);
5253    // doe ∈ [0, 146_097); fits in u32 with room to spare. Same for
5254    // every other quantity below — `as u32` truncations are safe by
5255    // construction.
5256    let doe = (z - era * 146_097) as u32;
5257    let yoe = (doe.saturating_sub(doe / 1460) + doe / 36524 - doe / 146_096) / 365;
5258    let y_base = i64::from(yoe) + era * 400;
5259    let doy = doe.saturating_sub(365 * yoe + yoe / 4 - yoe / 100);
5260    let mp = (5 * doy + 2) / 153;
5261    let d = doy.saturating_sub((153 * mp + 2) / 5) + 1;
5262    let m = if mp < 10 { mp + 3 } else { mp - 9 };
5263    let y = if m <= 2 { y_base + 1 } else { y_base };
5264    (y as i32, m, d)
5265}
5266
5267/// Inverse of `civil_from_days` — converts (year, month, day) to days
5268/// since 1970-01-01. Out-of-range months / days saturate.
5269#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5270pub fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
5271    let y_adj = if m <= 2 {
5272        i64::from(y) - 1
5273    } else {
5274        i64::from(y)
5275    };
5276    let era = y_adj.div_euclid(400);
5277    let yoe = (y_adj - era * 400) as u32;
5278    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d.saturating_sub(1);
5279    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
5280    let total = era * 146_097 + i64::from(doe) - 719_468;
5281    i32::try_from(total).unwrap_or(i32::MAX)
5282}
5283
5284/// Parse `YYYY-MM-DD` into a `Date` (days since Unix epoch). Returns
5285/// `None` on shape / numeric failure; the engine surfaces that as a
5286/// `TypeMismatch` with the original text included.
5287pub fn parse_date_literal(s: &str) -> Option<i32> {
5288    let bytes = s.as_bytes();
5289    if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
5290        return None;
5291    }
5292    let y: i32 = s[0..4].parse().ok()?;
5293    let m: u32 = s[5..7].parse().ok()?;
5294    let d: u32 = s[8..10].parse().ok()?;
5295    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
5296        return None;
5297    }
5298    Some(days_from_civil(y, m, d))
5299}
5300
5301/// Parse `YYYY-MM-DD[ HH:MM:SS[.ffffff]]` into a `Timestamp`
5302/// (microseconds since Unix epoch). The time portion is optional;
5303/// missing → midnight. The fractional portion accepts 1–6 digits and
5304/// pads with zeros to microseconds.
5305pub fn parse_timestamp_literal(s: &str) -> Option<i64> {
5306    let trimmed = s.trim();
5307    let (date_part, time_part) = match trimmed.find([' ', 'T']) {
5308        Some(i) => (&trimmed[..i], Some(&trimmed[i + 1..])),
5309        None => (trimmed, None),
5310    };
5311    let days = parse_date_literal(date_part)?;
5312    let (day_micros, tz_offset_micros) = match time_part {
5313        None => (0, 0),
5314        Some(t) => parse_time_of_day_micros(t)?,
5315    };
5316    // PG semantics: a TIMESTAMPTZ literal with an explicit offset
5317    // is normalised to UTC for storage. `'12:00:00+09'` means
5318    // 12:00:00 in a UTC+09 zone → 03:00:00 UTC → subtract the
5319    // positive offset (or add the negative one). Storage is i64
5320    // microseconds UTC for both TIMESTAMP and TIMESTAMPTZ (see
5321    // spg-storage::DataType::Timestamptz docs); the wire-level
5322    // round-trip then re-applies the session timezone on the
5323    // SELECT side when format_timestamp is asked for a TZ-aware
5324    // render.
5325    Some(i64::from(days) * 86_400_000_000 + day_micros - tz_offset_micros)
5326}
5327
5328/// v7.15.0 — Parse `HH:MM:SS[.frac][<tz>]` and return
5329/// `(day_micros, tz_offset_micros)` where `day_micros` is the
5330/// local-clock seconds-of-day in microseconds and
5331/// `tz_offset_micros` is the UTC offset (positive = east of
5332/// UTC, negative = west). Caller subtracts the offset to
5333/// normalise to UTC. PG's recognised TZ shapes after the
5334/// seconds (or frac) part:
5335///   * `+OO[:MM]` / `-OO[:MM]` — numeric offset
5336///   * `+OOMM` / `-OOMM` (no colon, less common but legal)
5337///   * ` UTC` / `UTC` / `Z` — explicit zero offset
5338/// Anything else after the seconds = parse failure (the caller
5339/// surfaces as "cannot parse … as TIMESTAMP").
5340fn parse_time_of_day_micros(t: &str) -> Option<(i64, i64)> {
5341    let t = t.trim();
5342    // Detect & strip optional TZ suffix. Anchor on the first
5343    // `+` / `-` AFTER position 8 (so the leading sign on a
5344    // negative offset can't be mistaken for an `HH:MM:SS-OO`
5345    // boundary if the time itself is somehow malformed).
5346    // ` UTC` and trailing `Z` also count as zero-offset TZ tags.
5347    let (core, tz_micros) = if let Some(rest) = t.strip_suffix('Z') {
5348        (rest, 0i64)
5349    } else if let Some(rest) = t.strip_suffix(" UTC").or_else(|| t.strip_suffix("UTC")) {
5350        (rest, 0i64)
5351    } else if let Some((idx, sign_byte)) = find_offset_sign(t) {
5352        let suffix = &t[idx..];
5353        let micros = parse_tz_offset_suffix(suffix, sign_byte == b'+')?;
5354        (&t[..idx], micros)
5355    } else {
5356        (t, 0i64)
5357    };
5358    let (time, frac_str) = match core.split_once('.') {
5359        Some((a, b)) => (a, Some(b)),
5360        None => (core, None),
5361    };
5362    let bytes = time.as_bytes();
5363    if bytes.len() != 8 || bytes[2] != b':' || bytes[5] != b':' {
5364        return None;
5365    }
5366    let hh: i64 = time[0..2].parse().ok()?;
5367    let mm: i64 = time[3..5].parse().ok()?;
5368    let ss: i64 = time[6..8].parse().ok()?;
5369    if !(0..24).contains(&hh) || !(0..60).contains(&mm) || !(0..60).contains(&ss) {
5370        return None;
5371    }
5372    let frac_micros: i64 = match frac_str {
5373        None => 0,
5374        Some(f) => {
5375            // Pad right with zeros to 6 digits, then truncate extras.
5376            if f.is_empty() || f.len() > 9 {
5377                return None;
5378            }
5379            let mut padded = String::with_capacity(6);
5380            padded.push_str(&f[..f.len().min(6)]);
5381            while padded.len() < 6 {
5382                padded.push('0');
5383            }
5384            padded.parse().ok()?
5385        }
5386    };
5387    Some((
5388        ((hh * 3600 + mm * 60 + ss) * 1_000_000) + frac_micros,
5389        tz_micros,
5390    ))
5391}
5392
5393/// Find the index of the TZ-offset sign byte (`+` or `-`) that
5394/// terminates an `HH:MM:SS[.fff]` time string, or `None` when
5395/// the time carries no numeric TZ suffix. Anchors past the first
5396/// 8 bytes (`HH:MM:SS`) so the seconds/minutes colons don't
5397/// confuse the scan.
5398fn find_offset_sign(t: &str) -> Option<(usize, u8)> {
5399    let bytes = t.as_bytes();
5400    // Start past `HH:MM:SS` (8 bytes).
5401    if bytes.len() < 9 {
5402        return None;
5403    }
5404    for i in 8..bytes.len() {
5405        match bytes[i] {
5406            b'+' | b'-' => return Some((i, bytes[i])),
5407            _ => {}
5408        }
5409    }
5410    None
5411}
5412
5413/// Parse `+OO`, `+OO:MM`, `+OOMM`, `-OO`, `-OO:MM`, `-OOMM` into
5414/// a UTC-offset microsecond delta. `is_positive` reflects the
5415/// already-stripped sign.
5416fn parse_tz_offset_suffix(suffix: &str, is_positive: bool) -> Option<i64> {
5417    // suffix starts with `+` or `-`; strip it.
5418    let body = &suffix[1..];
5419    let (hh, mm): (i64, i64) = if let Some((h, m)) = body.split_once(':') {
5420        (h.parse().ok()?, m.parse().ok()?)
5421    } else {
5422        match body.len() {
5423            2 => (body.parse().ok()?, 0),
5424            3 => {
5425                // PG's "+0530" form lacks the colon; but a 3-char
5426                // body is `OOM` which is ambiguous (`+053` ?). PG
5427                // doesn't emit that; reject.
5428                return None;
5429            }
5430            4 => {
5431                let h: i64 = body[0..2].parse().ok()?;
5432                let m: i64 = body[2..4].parse().ok()?;
5433                (h, m)
5434            }
5435            _ => return None,
5436        }
5437    };
5438    if !(0..=18).contains(&hh) || !(0..60).contains(&mm) {
5439        return None;
5440    }
5441    let abs = (hh * 3600 + mm * 60) * 1_000_000;
5442    Some(if is_positive { abs } else { -abs })
5443}
5444
5445/// Render an `Interval { months, micros }` in a PG-ish shape. The output
5446/// mirrors `psql`'s text format: years/months from the months part,
5447/// days/HH:MM:SS[.frac] from the microsecond part. Empty parts are
5448/// omitted; an all-zero interval renders as `0`.
5449pub fn format_interval(months: i32, micros: i64) -> String {
5450    const MICROS_PER_DAY: i64 = 86_400_000_000;
5451    let mut parts: Vec<String> = Vec::new();
5452    let years = months / 12;
5453    let mons = months % 12;
5454    // PG renders the unit in the singular only for `+1`; `-1` and any
5455    // other value pluralise. Helper closes over that rule.
5456    let unit = |n: i64, singular: &'static str, plural: &'static str| -> &'static str {
5457        if n == 1 { singular } else { plural }
5458    };
5459    if years != 0 {
5460        parts.push(format!(
5461            "{years} {}",
5462            unit(i64::from(years), "year", "years")
5463        ));
5464    }
5465    if mons != 0 {
5466        parts.push(format!("{mons} {}", unit(i64::from(mons), "mon", "mons")));
5467    }
5468    let days = micros / MICROS_PER_DAY;
5469    let mut rem = micros % MICROS_PER_DAY;
5470    if days != 0 {
5471        parts.push(format!("{days} {}", unit(days, "day", "days")));
5472    }
5473    if rem != 0 {
5474        let neg = rem < 0;
5475        if neg {
5476            rem = -rem;
5477        }
5478        let secs = rem / 1_000_000;
5479        let frac = rem % 1_000_000;
5480        let hh = secs / 3600;
5481        let mm = (secs / 60) % 60;
5482        let ss = secs % 60;
5483        let sign = if neg { "-" } else { "" };
5484        if frac == 0 {
5485            parts.push(format!("{sign}{hh:02}:{mm:02}:{ss:02}"));
5486        } else {
5487            let raw = format!("{frac:06}");
5488            let trimmed = raw.trim_end_matches('0');
5489            parts.push(format!("{sign}{hh:02}:{mm:02}:{ss:02}.{trimmed}"));
5490        }
5491    }
5492    if parts.is_empty() {
5493        "0".into()
5494    } else {
5495        parts.join(" ")
5496    }
5497}
5498
5499/// Add `months` (signed) to a `(year, month, day)` triple using PG's
5500/// clamp-to-last-day rule (so `'2024-01-31' + 1 month` → `'2024-02-29'`).
5501fn add_months_to_civil(y: i32, m: u32, d: u32, months: i32) -> (i32, u32, u32) {
5502    let total_months = i64::from(y) * 12 + i64::from(m) - 1 + i64::from(months);
5503    let new_year = i32::try_from(total_months.div_euclid(12)).unwrap_or(i32::MAX);
5504    let new_month_zero = total_months.rem_euclid(12);
5505    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5506    let new_month = (new_month_zero as u32) + 1;
5507    let max_day = days_in_month(new_year, new_month);
5508    (new_year, new_month, d.min(max_day))
5509}
5510
5511const fn days_in_month(y: i32, m: u32) -> u32 {
5512    match m {
5513        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
5514        2 => {
5515            // Proleptic Gregorian leap rule.
5516            if y.rem_euclid(4) == 0 && (y.rem_euclid(100) != 0 || y.rem_euclid(400) == 0) {
5517                29
5518            } else {
5519                28
5520            }
5521        }
5522        // 4 / 6 / 9 / 11 plus any out-of-range month (callers normalise
5523        // first, but be defensive) get the 30-day fallback.
5524        _ => 30,
5525    }
5526}
5527
5528/// v7.10.9 — render a TEXT[] in PG's external array form
5529/// (`{a,b,NULL}`). Elements containing whitespace, commas,
5530/// quotes, or braces get double-quoted with `\\` / `\"` escapes.
5531/// NULL elements use the literal token `NULL`. Public so the
5532/// wire layer can produce the canonical text-mode encoding.
5533pub fn format_text_array(items: &[Option<String>]) -> String {
5534    let mut out = String::with_capacity(2 + items.len() * 8);
5535    out.push('{');
5536    for (i, item) in items.iter().enumerate() {
5537        if i > 0 {
5538            out.push(',');
5539        }
5540        match item {
5541            None => out.push_str("NULL"),
5542            Some(s) => {
5543                let needs_quote = s.is_empty()
5544                    || s.eq_ignore_ascii_case("NULL")
5545                    || s.chars()
5546                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
5547                if needs_quote {
5548                    out.push('"');
5549                    for c in s.chars() {
5550                        if c == '"' || c == '\\' {
5551                            out.push('\\');
5552                        }
5553                        out.push(c);
5554                    }
5555                    out.push('"');
5556                } else {
5557                    out.push_str(s);
5558                }
5559            }
5560        }
5561    }
5562    out.push('}');
5563    out
5564}
5565
5566/// v7.11.14 — render an INT[] in PG's external array form
5567/// (`{1,2,NULL}`). Integer payloads never need quoting. NULL
5568/// elements use the literal token `NULL`.
5569pub fn format_int_array(items: &[Option<i32>]) -> String {
5570    let mut out = String::with_capacity(2 + items.len() * 4);
5571    out.push('{');
5572    for (i, item) in items.iter().enumerate() {
5573        if i > 0 {
5574            out.push(',');
5575        }
5576        match item {
5577            None => out.push_str("NULL"),
5578            Some(n) => out.push_str(&n.to_string()),
5579        }
5580    }
5581    out.push('}');
5582    out
5583}
5584
5585/// v7.11.14 — render a BIGINT[] in PG's external array form
5586/// (`{1,2,NULL}`).
5587pub fn format_bigint_array(items: &[Option<i64>]) -> String {
5588    let mut out = String::with_capacity(2 + items.len() * 6);
5589    out.push('{');
5590    for (i, item) in items.iter().enumerate() {
5591        if i > 0 {
5592            out.push(',');
5593        }
5594        match item {
5595            None => out.push_str("NULL"),
5596            Some(n) => out.push_str(&n.to_string()),
5597        }
5598    }
5599    out.push('}');
5600    out
5601}
5602
5603/// v7.12.0 — render a `tsvector` in PG's external form:
5604/// `'lex':1,2A 'word':3` (single-quoted lexemes, optional
5605/// `:positions`, optional weight letter `A/B/C/D` per position).
5606/// Lexemes already arrive sorted + deduped from the engine. Used
5607/// by the wire layer (OID 3614) and by SELECT-text output.
5608pub fn format_tsvector(lexs: &[TsLexeme]) -> String {
5609    let mut out = String::with_capacity(lexs.len() * 12);
5610    for (i, l) in lexs.iter().enumerate() {
5611        if i > 0 {
5612            out.push(' ');
5613        }
5614        out.push('\'');
5615        for c in l.word.chars() {
5616            if c == '\'' {
5617                out.push('\'');
5618            }
5619            out.push(c);
5620        }
5621        out.push('\'');
5622        if !l.positions.is_empty() {
5623            for (pi, p) in l.positions.iter().enumerate() {
5624                out.push(if pi == 0 { ':' } else { ',' });
5625                out.push_str(&p.to_string());
5626            }
5627            // v7.12.0 — weight is per-lexeme (the v7.12 design
5628            // collapses PG's per-position weight into one letter).
5629            // Emit once after the last position; default `D`
5630            // (weight=0) stays implicit.
5631            match l.weight {
5632                3 => out.push('A'),
5633                2 => out.push('B'),
5634                1 => out.push('C'),
5635                _ => {}
5636            }
5637        }
5638    }
5639    out
5640}
5641
5642/// v7.12.0 — render a `tsquery` in PG's external form. Operator
5643/// precedence: `!` > `&` > `|`. Phrase distance shown as `<N>`.
5644pub fn format_tsquery(ast: &TsQueryAst) -> String {
5645    fn go(ast: &TsQueryAst, parent_prec: u8, out: &mut String) {
5646        // 0 = top, 1 = OR, 2 = AND, 3 = NOT/Phrase, 4 = atom.
5647        let (own_prec, write_self): (u8, &dyn Fn(&mut String)) = match ast {
5648            TsQueryAst::Or(_, _) => (1, &|_| {}),
5649            TsQueryAst::And(_, _) | TsQueryAst::Phrase { .. } => (2, &|_| {}),
5650            TsQueryAst::Not(_) => (3, &|_| {}),
5651            TsQueryAst::Term { .. } => (4, &|_| {}),
5652        };
5653        let need_parens = own_prec < parent_prec;
5654        if need_parens {
5655            out.push('(');
5656        }
5657        match ast {
5658            TsQueryAst::Term { word, .. } => {
5659                out.push('\'');
5660                for c in word.chars() {
5661                    if c == '\'' {
5662                        out.push('\'');
5663                    }
5664                    out.push(c);
5665                }
5666                out.push('\'');
5667            }
5668            TsQueryAst::And(a, b) => {
5669                go(a, own_prec, out);
5670                out.push_str(" & ");
5671                go(b, own_prec, out);
5672            }
5673            TsQueryAst::Or(a, b) => {
5674                go(a, own_prec, out);
5675                out.push_str(" | ");
5676                go(b, own_prec, out);
5677            }
5678            TsQueryAst::Not(x) => {
5679                out.push('!');
5680                go(x, own_prec, out);
5681            }
5682            TsQueryAst::Phrase {
5683                left,
5684                right,
5685                distance,
5686            } => {
5687                go(left, own_prec, out);
5688                out.push_str(&alloc::format!(" <{distance}> "));
5689                go(right, own_prec, out);
5690            }
5691        }
5692        write_self(out);
5693        if need_parens {
5694            out.push(')');
5695        }
5696    }
5697    let mut out = String::new();
5698    go(ast, 0, &mut out);
5699    out
5700}
5701
5702/// v7.12.0 — decode PG external form `'word':1,2A 'other':3` into
5703/// a `Vec<TsLexeme>`. Lexemes are sorted ascending by `word` (with
5704/// duplicates merged on positions) so the output matches the
5705/// engine invariant. Empty input yields an empty vector.
5706///
5707/// v7.12.0 only ships the cast-literal entry. Full `to_tsvector`
5708/// (Unicode word-split + Porter stemming + stopwords) lands in
5709/// v7.12.1.
5710pub fn decode_tsvector_external(s: &str) -> Result<Vec<TsLexeme>, EvalError> {
5711    let mut out: Vec<TsLexeme> = Vec::new();
5712    let mut i = 0;
5713    let bytes = s.as_bytes();
5714    while i < bytes.len() {
5715        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
5716            i += 1;
5717        }
5718        if i >= bytes.len() {
5719            break;
5720        }
5721        // Quoted form `'word'` (with embedded `''` for a literal
5722        // single quote, mirroring PG).
5723        let word = if bytes[i] == b'\'' {
5724            i += 1;
5725            let mut w = String::new();
5726            loop {
5727                if i >= bytes.len() {
5728                    return Err(EvalError::TypeMismatch {
5729                        detail: "tsvector literal: unterminated quoted lexeme".into(),
5730                    });
5731                }
5732                let b = bytes[i];
5733                if b == b'\'' {
5734                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
5735                        w.push('\'');
5736                        i += 2;
5737                    } else {
5738                        i += 1;
5739                        break;
5740                    }
5741                } else {
5742                    w.push(b as char);
5743                    i += 1;
5744                }
5745            }
5746            w
5747        } else {
5748            // Bare form — read until whitespace, ':' or end.
5749            let start = i;
5750            while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b':' {
5751                i += 1;
5752            }
5753            core::str::from_utf8(&bytes[start..i])
5754                .map_err(|_| EvalError::TypeMismatch {
5755                    detail: "tsvector literal: non-UTF-8 lexeme".into(),
5756                })?
5757                .to_string()
5758        };
5759        if word.is_empty() {
5760            return Err(EvalError::TypeMismatch {
5761                detail: "tsvector literal: empty lexeme".into(),
5762            });
5763        }
5764        // Optional `:pos[,pos][,pos]`. Each position is u16; each
5765        // may carry a trailing weight letter A/B/C/D.
5766        let mut positions: Vec<u16> = Vec::new();
5767        let mut weight: u8 = 0;
5768        if i < bytes.len() && bytes[i] == b':' {
5769            i += 1;
5770            loop {
5771                let start = i;
5772                while i < bytes.len() && bytes[i].is_ascii_digit() {
5773                    i += 1;
5774                }
5775                if start == i {
5776                    return Err(EvalError::TypeMismatch {
5777                        detail: "tsvector literal: expected digit after ':'".into(),
5778                    });
5779                }
5780                let num: u16 = core::str::from_utf8(&bytes[start..i])
5781                    .expect("ascii digits")
5782                    .parse()
5783                    .map_err(|_| EvalError::TypeMismatch {
5784                        detail: alloc::format!(
5785                            "tsvector literal: position {} overflows u16",
5786                            core::str::from_utf8(&bytes[start..i]).unwrap_or("?")
5787                        ),
5788                    })?;
5789                positions.push(num);
5790                if i < bytes.len() {
5791                    let w = bytes[i];
5792                    if matches!(w, b'A' | b'B' | b'C' | b'D') {
5793                        weight = match w {
5794                            b'A' => 3,
5795                            b'B' => 2,
5796                            b'C' => 1,
5797                            _ => 0,
5798                        };
5799                        i += 1;
5800                    }
5801                }
5802                if i < bytes.len() && bytes[i] == b',' {
5803                    i += 1;
5804                    continue;
5805                }
5806                break;
5807            }
5808        }
5809        positions.sort_unstable();
5810        positions.dedup();
5811        // Merge into the output vector — sorted insert by word,
5812        // duplicate words merge positions.
5813        match out.binary_search_by(|l| l.word.as_str().cmp(word.as_str())) {
5814            Ok(idx) => {
5815                for p in positions {
5816                    if !out[idx].positions.contains(&p) {
5817                        out[idx].positions.push(p);
5818                    }
5819                }
5820                out[idx].positions.sort_unstable();
5821                if weight != 0 {
5822                    out[idx].weight = weight;
5823                }
5824            }
5825            Err(idx) => {
5826                out.insert(
5827                    idx,
5828                    TsLexeme {
5829                        word,
5830                        positions,
5831                        weight,
5832                    },
5833                );
5834            }
5835        }
5836    }
5837    Ok(out)
5838}
5839
5840/// v7.12.0 — decode PG external form `'foo' & 'bar' | !'baz'`
5841/// into a `TsQueryAst`. v7.12.0 supports the canonical
5842/// `to_tsquery` surface: single-quoted lexemes, `&` / `|` / `!`,
5843/// parens, and phrase `<N>`. Bare lexemes are accepted too. Full
5844/// `plainto_tsquery` / `websearch_to_tsquery` arrive in v7.12.1.
5845pub fn decode_tsquery_external(s: &str) -> Result<TsQueryAst, EvalError> {
5846    let mut p = TsQueryParser {
5847        bytes: s.as_bytes(),
5848        pos: 0,
5849    };
5850    p.skip_ws();
5851    if p.pos >= p.bytes.len() {
5852        return Err(EvalError::TypeMismatch {
5853            detail: "tsquery literal: empty".into(),
5854        });
5855    }
5856    let ast = p.parse_or()?;
5857    p.skip_ws();
5858    if p.pos < p.bytes.len() {
5859        return Err(EvalError::TypeMismatch {
5860            detail: alloc::format!("tsquery literal: trailing garbage at offset {}", p.pos),
5861        });
5862    }
5863    Ok(ast)
5864}
5865
5866struct TsQueryParser<'a> {
5867    bytes: &'a [u8],
5868    pos: usize,
5869}
5870
5871impl<'a> TsQueryParser<'a> {
5872    fn skip_ws(&mut self) {
5873        while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
5874            self.pos += 1;
5875        }
5876    }
5877    fn peek(&self) -> Option<u8> {
5878        self.bytes.get(self.pos).copied()
5879    }
5880    fn parse_or(&mut self) -> Result<TsQueryAst, EvalError> {
5881        let mut lhs = self.parse_and()?;
5882        loop {
5883            self.skip_ws();
5884            if self.peek() != Some(b'|') {
5885                return Ok(lhs);
5886            }
5887            self.pos += 1;
5888            let rhs = self.parse_and()?;
5889            lhs = TsQueryAst::Or(Box::new(lhs), Box::new(rhs));
5890        }
5891    }
5892    fn parse_and(&mut self) -> Result<TsQueryAst, EvalError> {
5893        let mut lhs = self.parse_unary()?;
5894        loop {
5895            self.skip_ws();
5896            match self.peek() {
5897                Some(b'&') => {
5898                    self.pos += 1;
5899                    let rhs = self.parse_unary()?;
5900                    lhs = TsQueryAst::And(Box::new(lhs), Box::new(rhs));
5901                }
5902                Some(b'<') => {
5903                    // Phrase distance `<N>`.
5904                    self.pos += 1;
5905                    let start = self.pos;
5906                    while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
5907                        self.pos += 1;
5908                    }
5909                    if start == self.pos || self.peek() != Some(b'>') {
5910                        return Err(EvalError::TypeMismatch {
5911                            detail: "tsquery literal: malformed <N> phrase operator".into(),
5912                        });
5913                    }
5914                    let n: u16 = core::str::from_utf8(&self.bytes[start..self.pos])
5915                        .expect("ascii digits")
5916                        .parse()
5917                        .map_err(|_| EvalError::TypeMismatch {
5918                            detail: "tsquery literal: phrase distance overflows u16".into(),
5919                        })?;
5920                    self.pos += 1; // consume '>'
5921                    let rhs = self.parse_unary()?;
5922                    lhs = TsQueryAst::Phrase {
5923                        left: Box::new(lhs),
5924                        right: Box::new(rhs),
5925                        distance: n,
5926                    };
5927                }
5928                _ => return Ok(lhs),
5929            }
5930        }
5931    }
5932    fn parse_unary(&mut self) -> Result<TsQueryAst, EvalError> {
5933        self.skip_ws();
5934        if self.peek() == Some(b'!') {
5935            self.pos += 1;
5936            let inner = self.parse_unary()?;
5937            return Ok(TsQueryAst::Not(Box::new(inner)));
5938        }
5939        self.parse_atom()
5940    }
5941    fn parse_atom(&mut self) -> Result<TsQueryAst, EvalError> {
5942        self.skip_ws();
5943        match self.peek() {
5944            Some(b'(') => {
5945                self.pos += 1;
5946                let inner = self.parse_or()?;
5947                self.skip_ws();
5948                if self.peek() != Some(b')') {
5949                    return Err(EvalError::TypeMismatch {
5950                        detail: "tsquery literal: missing ')'".into(),
5951                    });
5952                }
5953                self.pos += 1;
5954                Ok(inner)
5955            }
5956            Some(b'\'') => {
5957                self.pos += 1;
5958                let mut w = String::new();
5959                loop {
5960                    match self.peek() {
5961                        None => {
5962                            return Err(EvalError::TypeMismatch {
5963                                detail: "tsquery literal: unterminated quoted lexeme".into(),
5964                            });
5965                        }
5966                        Some(b'\'') => {
5967                            if self.bytes.get(self.pos + 1) == Some(&b'\'') {
5968                                w.push('\'');
5969                                self.pos += 2;
5970                            } else {
5971                                self.pos += 1;
5972                                break;
5973                            }
5974                        }
5975                        Some(b) => {
5976                            w.push(b as char);
5977                            self.pos += 1;
5978                        }
5979                    }
5980                }
5981                // Optional `:WEIGHT_MASK` (digit-mask) — v7.12.0
5982                // accepts but always stores 0 (any).
5983                self.skip_weight_suffix();
5984                Ok(TsQueryAst::Term {
5985                    word: w,
5986                    weight_mask: 0,
5987                })
5988            }
5989            Some(b) if b.is_ascii_alphanumeric() || b == b'_' => {
5990                let start = self.pos;
5991                while self.pos < self.bytes.len() {
5992                    let c = self.bytes[self.pos];
5993                    if c.is_ascii_alphanumeric() || c == b'_' {
5994                        self.pos += 1;
5995                    } else {
5996                        break;
5997                    }
5998                }
5999                let w = core::str::from_utf8(&self.bytes[start..self.pos])
6000                    .map_err(|_| EvalError::TypeMismatch {
6001                        detail: "tsquery literal: non-UTF-8 lexeme".into(),
6002                    })?
6003                    .to_string();
6004                self.skip_weight_suffix();
6005                Ok(TsQueryAst::Term {
6006                    word: w,
6007                    weight_mask: 0,
6008                })
6009            }
6010            Some(b) => Err(EvalError::TypeMismatch {
6011                detail: alloc::format!(
6012                    "tsquery literal: unexpected byte {:?} at offset {}",
6013                    b as char,
6014                    self.pos
6015                ),
6016            }),
6017            None => Err(EvalError::TypeMismatch {
6018                detail: "tsquery literal: expected term".into(),
6019            }),
6020        }
6021    }
6022    fn skip_weight_suffix(&mut self) {
6023        if self.peek() != Some(b':') {
6024            return;
6025        }
6026        self.pos += 1;
6027        while let Some(b) = self.peek() {
6028            if matches!(
6029                b,
6030                b'A' | b'B' | b'C' | b'D' | b'a' | b'b' | b'c' | b'd' | b'*'
6031            ) || b.is_ascii_digit()
6032            {
6033                self.pos += 1;
6034            } else {
6035                break;
6036            }
6037        }
6038    }
6039}
6040
6041/// v7.10.4 — render a BYTEA payload in PG's hex output format
6042/// (`\x` prefix, lowercase hex pairs). Public so the wire layer
6043/// can emit the canonical bytea-as-text representation.
6044pub fn format_bytea_hex(b: &[u8]) -> String {
6045    let mut out = String::with_capacity(2 + 2 * b.len());
6046    out.push_str("\\x");
6047    const HEX: &[u8; 16] = b"0123456789abcdef";
6048    for byte in b {
6049        out.push(HEX[(byte >> 4) as usize] as char);
6050        out.push(HEX[(byte & 0x0F) as usize] as char);
6051    }
6052    out
6053}
6054
6055/// Render a `Numeric { scaled, scale }` as its decimal text form.
6056/// Negative `scaled` prepends `-` to the absolute value's digits; the
6057/// integer / fractional split is by character count, padding the
6058/// fractional side with leading zeros to exactly `scale` chars.
6059pub fn format_numeric(scaled: i128, scale: u8) -> String {
6060    if scale == 0 {
6061        return format!("{scaled}");
6062    }
6063    let negative = scaled < 0;
6064    let mag_str = scaled.unsigned_abs().to_string();
6065    let mag_bytes = mag_str.as_bytes();
6066    let scale_u = scale as usize;
6067    let mut out = String::with_capacity(mag_str.len() + 3);
6068    if negative {
6069        out.push('-');
6070    }
6071    if mag_bytes.len() <= scale_u {
6072        out.push('0');
6073        out.push('.');
6074        for _ in mag_bytes.len()..scale_u {
6075            out.push('0');
6076        }
6077        out.push_str(&mag_str);
6078    } else {
6079        let split = mag_bytes.len() - scale_u;
6080        out.push_str(&mag_str[..split]);
6081        out.push('.');
6082        out.push_str(&mag_str[split..]);
6083    }
6084    out
6085}
6086
6087fn cast_numeric_to_int(v: Value) -> Result<Value, EvalError> {
6088    match v {
6089        Value::Int(n) => Ok(Value::Int(n)),
6090        Value::BigInt(n) => i32::try_from(n)
6091            .map(Value::Int)
6092            .map_err(|_| EvalError::TypeMismatch {
6093                detail: format!("bigint {n} does not fit in int"),
6094            }),
6095        #[allow(clippy::cast_possible_truncation)]
6096        Value::Float(x) => Ok(Value::Int(x as i32)),
6097        Value::Text(s) => {
6098            s.trim()
6099                .parse::<i32>()
6100                .map(Value::Int)
6101                .map_err(|_| EvalError::TypeMismatch {
6102                    detail: format!("cannot parse {s:?} as int"),
6103                })
6104        }
6105        Value::Bool(b) => Ok(Value::Int(i32::from(b))),
6106        other => Err(EvalError::TypeMismatch {
6107            detail: format!("cannot cast {:?} to int", other.data_type()),
6108        }),
6109    }
6110}
6111
6112fn cast_numeric_to_bigint(v: Value) -> Result<Value, EvalError> {
6113    match v {
6114        Value::Int(n) => Ok(Value::BigInt(i64::from(n))),
6115        Value::BigInt(n) => Ok(Value::BigInt(n)),
6116        #[allow(clippy::cast_possible_truncation)]
6117        Value::Float(x) => Ok(Value::BigInt(x as i64)),
6118        Value::Text(s) => {
6119            s.trim()
6120                .parse::<i64>()
6121                .map(Value::BigInt)
6122                .map_err(|_| EvalError::TypeMismatch {
6123                    detail: format!("cannot parse {s:?} as bigint"),
6124                })
6125        }
6126        Value::Bool(b) => Ok(Value::BigInt(i64::from(b))),
6127        other => Err(EvalError::TypeMismatch {
6128            detail: format!("cannot cast {:?} to bigint", other.data_type()),
6129        }),
6130    }
6131}
6132
6133fn cast_numeric_to_float(v: Value) -> Result<Value, EvalError> {
6134    match v {
6135        Value::Int(n) => Ok(Value::Float(f64::from(n))),
6136        #[allow(clippy::cast_precision_loss)]
6137        Value::BigInt(n) => Ok(Value::Float(n as f64)),
6138        Value::Float(x) => Ok(Value::Float(x)),
6139        Value::Text(s) => {
6140            s.trim()
6141                .parse::<f64>()
6142                .map(Value::Float)
6143                .map_err(|_| EvalError::TypeMismatch {
6144                    detail: format!("cannot parse {s:?} as float"),
6145                })
6146        }
6147        other => Err(EvalError::TypeMismatch {
6148            detail: format!("cannot cast {:?} to float", other.data_type()),
6149        }),
6150    }
6151}
6152
6153fn cast_to_bool(v: Value) -> Result<Value, EvalError> {
6154    match v {
6155        Value::Bool(b) => Ok(Value::Bool(b)),
6156        Value::Int(n) => Ok(Value::Bool(n != 0)),
6157        Value::BigInt(n) => Ok(Value::Bool(n != 0)),
6158        Value::Text(s) => {
6159            let lo = s.trim().to_ascii_lowercase();
6160            match lo.as_str() {
6161                "true" | "t" | "yes" | "y" | "1" | "on" => Ok(Value::Bool(true)),
6162                "false" | "f" | "no" | "n" | "0" | "off" => Ok(Value::Bool(false)),
6163                _ => Err(EvalError::TypeMismatch {
6164                    detail: format!("cannot parse {s:?} as bool"),
6165                }),
6166            }
6167        }
6168        other => Err(EvalError::TypeMismatch {
6169            detail: format!("cannot cast {:?} to bool", other.data_type()),
6170        }),
6171    }
6172}
6173
6174/// Parse a `Value::Text("[1.0, 2.0, 3.0]")` into a `Value::Vector(..)`. Mirrors
6175/// pgvector's `'[..]'::vector` cast. NULL casts as NULL.
6176pub fn cast_to_vector(v: Value) -> Result<Value, EvalError> {
6177    match v {
6178        Value::Null => Ok(Value::Null),
6179        Value::Vector(v) => Ok(Value::Vector(v)),
6180        Value::Text(s) => parse_vector_text(&s)
6181            .map(Value::Vector)
6182            .ok_or(EvalError::TypeMismatch {
6183                detail: format!("cannot parse {s:?} as a vector literal"),
6184            }),
6185        other => Err(EvalError::TypeMismatch {
6186            detail: format!("::vector requires text input, got {:?}", other.data_type()),
6187        }),
6188    }
6189}
6190
6191/// Parse `"[1.0, 2.0, -3]"` into `Vec<f32>`. Returns `None` on malformed input.
6192pub fn parse_vector_text(s: &str) -> Option<Vec<f32>> {
6193    let trimmed = s.trim();
6194    let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
6195    let trimmed_inner = inner.trim();
6196    if trimmed_inner.is_empty() {
6197        return Some(Vec::new());
6198    }
6199    let mut out = Vec::new();
6200    for part in trimmed_inner.split(',') {
6201        let f: f32 = part.trim().parse().ok()?;
6202        out.push(f);
6203    }
6204    Some(out)
6205}
6206
6207fn literal_to_value(l: &Literal) -> Value {
6208    match l {
6209        Literal::Integer(n) => {
6210            if let Ok(small) = i32::try_from(*n) {
6211                Value::Int(small)
6212            } else {
6213                Value::BigInt(*n)
6214            }
6215        }
6216        Literal::Float(x) => Value::Float(*x),
6217        Literal::String(s) => Value::Text(s.clone()),
6218        Literal::Vector(v) => Value::Vector(v.clone()),
6219        Literal::TextArray(items) => Value::TextArray(items.clone()),
6220        Literal::IntArray(items) => Value::IntArray(items.clone()),
6221        Literal::BigIntArray(items) => Value::BigIntArray(items.clone()),
6222        Literal::Bool(b) => Value::Bool(*b),
6223        Literal::Null => Value::Null,
6224        Literal::Interval { months, micros, .. } => Value::Interval {
6225            months: *months,
6226            micros: *micros,
6227        },
6228    }
6229}
6230
6231/// v7.17.0 Phase 2.5 — look up the collation of a column reference
6232/// in the current evaluation context. Returns `None` when the
6233/// expression is not a column reference (e.g. literal / function
6234/// call) or the column can't be resolved (caller falls back to
6235/// `Collation::Binary` semantics).
6236pub(crate) fn column_collation(e: &Expr, ctx: &EvalContext<'_>) -> Option<spg_storage::Collation> {
6237    let Expr::Column(c) = e else {
6238        return None;
6239    };
6240    if let Some(q) = &c.qualifier {
6241        let composite = alloc::format!("{q}.{name}", name = c.name);
6242        if let Some(s) = ctx.columns.iter().find(|s| s.name == composite) {
6243            return Some(s.collation);
6244        }
6245    }
6246    if let Some(s) = ctx.columns.iter().find(|s| s.name == c.name) {
6247        return Some(s.collation);
6248    }
6249    // Bare-name fallback for joined schemas (same shape as
6250    // resolve_column): match a single composite ending in
6251    // ".<name>".
6252    let suffix = alloc::format!(".{name}", name = c.name);
6253    let mut matches = ctx.columns.iter().filter(|s| s.name.ends_with(&suffix));
6254    let first = matches.next();
6255    let extra = matches.next();
6256    match (first, extra) {
6257        (Some(s), None) => Some(s.collation),
6258        _ => None,
6259    }
6260}
6261
6262/// v7.17.0 Phase 2.5 — if the comparison op is text-equality and
6263/// either operand references a CaseInsensitive column, return
6264/// ASCII-folded copies of both Text values; otherwise pass
6265/// through. Only Eq / NotEq / Lt / LtEq / Gt / GtEq trigger the
6266/// fold — relational operators on text still honour collation
6267/// the same way (PG semantics). Non-Text values pass through.
6268fn collation_fold_for_compare(
6269    op: BinOp,
6270    lhs: &Expr,
6271    rhs: &Expr,
6272    l: Value,
6273    r: Value,
6274    ctx: &EvalContext<'_>,
6275) -> (Value, Value) {
6276    if !matches!(
6277        op,
6278        BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
6279    ) {
6280        return (l, r);
6281    }
6282    let lhs_col = column_collation(lhs, ctx);
6283    let rhs_col = column_collation(rhs, ctx);
6284    let ci = matches!(lhs_col, Some(spg_storage::Collation::CaseInsensitive))
6285        || matches!(rhs_col, Some(spg_storage::Collation::CaseInsensitive));
6286    if !ci {
6287        return (l, r);
6288    }
6289    let fold = |v: Value| match v {
6290        Value::Text(s) => Value::Text(s.to_ascii_lowercase()),
6291        other => other,
6292    };
6293    (fold(l), fold(r))
6294}
6295
6296fn resolve_column(c: &ColumnName, row: &Row, ctx: &EvalContext<'_>) -> Result<Value, EvalError> {
6297    if let Some(q) = &c.qualifier {
6298        // Multi-table evaluation (joins): the synthesised schema uses
6299        // composite column names "alias.column" so we look that up
6300        // directly. Falls back to the single-table case below if the
6301        // composite isn't present.
6302        let composite = alloc::format!("{q}.{name}", name = c.name);
6303        if let Some(pos) = ctx.columns.iter().position(|s| s.name == composite) {
6304            return Ok(row.values[pos].clone());
6305        }
6306        let expected = ctx.table_alias.ok_or_else(|| EvalError::UnknownQualifier {
6307            qualifier: q.clone(),
6308        })?;
6309        if q != expected {
6310            return Err(EvalError::UnknownQualifier {
6311                qualifier: q.clone(),
6312            });
6313        }
6314    }
6315    if let Some(pos) = ctx.columns.iter().position(|s| s.name == c.name) {
6316        return Ok(row.values[pos].clone());
6317    }
6318    // Bare-name fallback for joined schemas: match any single composite
6319    // column ending in ".<name>"; ambiguity is an error.
6320    let suffix = alloc::format!(".{name}", name = c.name);
6321    let mut matches = ctx
6322        .columns
6323        .iter()
6324        .enumerate()
6325        .filter(|(_, s)| s.name.ends_with(&suffix));
6326    let first = matches.next();
6327    let extra = matches.next();
6328    match (first, extra) {
6329        (Some((pos, _)), None) => Ok(row.values[pos].clone()),
6330        (Some(_), Some(_)) => Err(EvalError::TypeMismatch {
6331            detail: alloc::format!("ambiguous column reference: {}", c.name),
6332        }),
6333        _ => Err(EvalError::ColumnNotFound {
6334            name: c.name.clone(),
6335        }),
6336    }
6337}
6338
6339fn apply_unary(op: UnOp, v: Value) -> Result<Value, EvalError> {
6340    match (op, v) {
6341        (_, Value::Null) => Ok(Value::Null),
6342        (UnOp::Neg, Value::Int(n)) => {
6343            n.checked_neg()
6344                .map(Value::Int)
6345                .ok_or(EvalError::TypeMismatch {
6346                    detail: "integer overflow on unary -".into(),
6347                })
6348        }
6349        (UnOp::Neg, Value::BigInt(n)) => {
6350            n.checked_neg()
6351                .map(Value::BigInt)
6352                .ok_or(EvalError::TypeMismatch {
6353                    detail: "bigint overflow on unary -".into(),
6354                })
6355        }
6356        (UnOp::Neg, Value::Float(x)) => Ok(Value::Float(-x)),
6357        (UnOp::Neg, other) => Err(EvalError::TypeMismatch {
6358            detail: format!("unary - applied to {:?}", other.data_type()),
6359        }),
6360        (UnOp::BitNot, Value::SmallInt(n)) => Ok(Value::Int(!i32::from(n))),
6361        (UnOp::BitNot, Value::Int(n)) => Ok(Value::Int(!n)),
6362        (UnOp::BitNot, Value::BigInt(n)) => Ok(Value::BigInt(!n)),
6363        (UnOp::BitNot, other) => Err(EvalError::TypeMismatch {
6364            detail: format!("cannot apply ~ to {other:?}"),
6365        }),
6366        (UnOp::Not, Value::Bool(b)) => Ok(Value::Bool(!b)),
6367        (UnOp::Not, other) => Err(EvalError::TypeMismatch {
6368            detail: format!("NOT applied to {:?}", other.data_type()),
6369        }),
6370    }
6371}
6372
6373/// v7.9.27b — true when two values are "not distinct" per PG:
6374/// both NULL counts as equal; otherwise reduces to regular Eq.
6375fn values_not_distinct(l: &Value, r: &Value) -> bool {
6376    match (l, r) {
6377        (Value::Null, Value::Null) => true,
6378        (Value::Null, _) | (_, Value::Null) => false,
6379        _ => l == r,
6380    }
6381}
6382
6383fn apply_binary(op: BinOp, l: Value, r: Value) -> Result<Value, EvalError> {
6384    // SQL three-valued logic for AND / OR with NULL is special — handle before
6385    // the general NULL-propagation rule.
6386    if let BinOp::And = op {
6387        return and_3vl(l, r);
6388    }
6389    if let BinOp::Or = op {
6390        return or_3vl(l, r);
6391    }
6392    // v7.9.27b — IS [NOT] DISTINCT FROM. NULL-safe equality:
6393    // `NULL IS NOT DISTINCT FROM NULL` → true. mailrs pg_dump.
6394    if let BinOp::IsNotDistinctFrom = op {
6395        return Ok(Value::Bool(values_not_distinct(&l, &r)));
6396    }
6397    if let BinOp::IsDistinctFrom = op {
6398        return Ok(Value::Bool(!values_not_distinct(&l, &r)));
6399    }
6400    // Everything else: any NULL operand → NULL.
6401    if l.is_null() || r.is_null() {
6402        return Ok(Value::Null);
6403    }
6404    // NUMERIC arithmetic and comparisons run in fixed-point; promote
6405    // integers to a common NUMERIC scale and stay in i128 throughout.
6406    if matches!(l, Value::Numeric { .. }) || matches!(r, Value::Numeric { .. }) {
6407        return apply_binary_numeric(op, l, r);
6408    }
6409    // Date / Timestamp arithmetic. PG semantics:
6410    //   * date + int      → date  (int is days)
6411    //   * int + date      → date
6412    //   * date - int      → date
6413    //   * date - date     → int   (days, signed)
6414    //   * timestamp - timestamp → bigint (microseconds, signed)
6415    // Other date/time math (`timestamp + int`, INTERVAL) lands later.
6416    if let Some(result) = apply_binary_calendar(op, &l, &r)? {
6417        return Ok(result);
6418    }
6419    match op {
6420        BinOp::Add => arith(l, r, i64::checked_add, |a, b| a + b, "+"),
6421        BinOp::Sub => arith(l, r, i64::checked_sub, |a, b| a - b, "-"),
6422        BinOp::Mul => arith(l, r, i64::checked_mul, |a, b| a * b, "*"),
6423        BinOp::Div => div_op(l, r),
6424        BinOp::L2Distance => l2_distance(l, r),
6425        BinOp::InnerProduct => inner_product(l, r),
6426        BinOp::CosineDistance => cosine_distance(l, r),
6427        BinOp::Concat => Ok(text_concat(&l, &r)),
6428        BinOp::BitOr => bitop(l, r, |a, b| a | b, "|"),
6429        BinOp::BitAnd => bitop(l, r, |a, b| a & b, "&"),
6430        BinOp::JsonGet => crate::json::path_get(&l, &r, false),
6431        BinOp::JsonGetText => crate::json::path_get(&l, &r, true),
6432        BinOp::JsonGetPath => crate::json::path_walk(&l, &r, false),
6433        BinOp::JsonGetPathText => crate::json::path_walk(&l, &r, true),
6434        BinOp::JsonContains => crate::json::contains(&l, &r),
6435        // v7.12.2 — `@@` match. NULL on either side → NULL; PG
6436        // accepts both orderings so we normalise.
6437        BinOp::TsMatch => ts_match(l, r),
6438        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
6439        BinOp::InetContainedBy
6440        | BinOp::InetContainedByEq
6441        | BinOp::InetContains
6442        | BinOp::InetContainsEq
6443        | BinOp::InetOverlap => inet_op_bool_result(op, &l, &r),
6444        BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq => {
6445            compare(op, &l, &r)
6446        }
6447        BinOp::And | BinOp::Or | BinOp::IsDistinctFrom | BinOp::IsNotDistinctFrom => {
6448            unreachable!("handled above")
6449        }
6450    }
6451}
6452
6453/// Calendar arithmetic. Returns `Some(value)` when the operand pair
6454/// is a date/time combo this function understands, `None` to let the
6455/// caller fall through to the regular numeric / text paths.
6456fn apply_binary_calendar(op: BinOp, l: &Value, r: &Value) -> Result<Option<Value>, EvalError> {
6457    let int_value = |v: &Value| -> Option<i64> {
6458        match v {
6459            Value::SmallInt(n) => Some(i64::from(*n)),
6460            Value::Int(n) => Some(i64::from(*n)),
6461            Value::BigInt(n) => Some(*n),
6462            _ => None,
6463        }
6464    };
6465    // Most-specific cases first — DATE-DATE / TS-TS subtraction before
6466    // DATE-integer subtraction, otherwise the latter swallows the
6467    // former with an `int_value(Date) = None` no-op fall-through.
6468    match (l, r) {
6469        (Value::Date(a), Value::Date(b)) if op == BinOp::Sub => {
6470            return Ok(Some(Value::BigInt(i64::from(*a) - i64::from(*b))));
6471        }
6472        (Value::Timestamp(a), Value::Timestamp(b)) if op == BinOp::Sub => {
6473            let delta = a.checked_sub(*b).ok_or(EvalError::TypeMismatch {
6474                detail: "TIMESTAMP - TIMESTAMP overflows i64 microseconds".into(),
6475            })?;
6476            return Ok(Some(Value::BigInt(delta)));
6477        }
6478        _ => {}
6479    }
6480    // INTERVAL arithmetic. PG: timestamp ± interval → timestamp,
6481    // date ± interval → date (if interval is pure days/months with no
6482    // sub-day component) else timestamp, interval ± interval → interval.
6483    if let Some(out) = apply_binary_interval(op, l, r)? {
6484        return Ok(Some(out));
6485    }
6486    match (l, r) {
6487        (Value::Date(d), other) if op == BinOp::Add => {
6488            if let Some(n) = int_value(other) {
6489                let days = i64::from(*d).saturating_add(n);
6490                let days32 = i32::try_from(days).map_err(|_| EvalError::TypeMismatch {
6491                    detail: "DATE + integer overflows DATE range".into(),
6492                })?;
6493                return Ok(Some(Value::Date(days32)));
6494            }
6495        }
6496        (other, Value::Date(d)) if op == BinOp::Add => {
6497            if let Some(n) = int_value(other) {
6498                let days = i64::from(*d).saturating_add(n);
6499                let days32 = i32::try_from(days).map_err(|_| EvalError::TypeMismatch {
6500                    detail: "integer + DATE overflows DATE range".into(),
6501                })?;
6502                return Ok(Some(Value::Date(days32)));
6503            }
6504        }
6505        (Value::Date(d), other) if op == BinOp::Sub => {
6506            if let Some(n) = int_value(other) {
6507                let days = i64::from(*d).saturating_sub(n);
6508                let days32 = i32::try_from(days).map_err(|_| EvalError::TypeMismatch {
6509                    detail: "DATE - integer overflows DATE range".into(),
6510                })?;
6511                return Ok(Some(Value::Date(days32)));
6512            }
6513        }
6514        _ => {}
6515    }
6516    Ok(None)
6517}
6518
6519/// INTERVAL-aware binary ops. Recognises:
6520///   timestamp ± interval → timestamp
6521///   date ± interval      → date (if interval is integral days/months only)
6522///                       → timestamp (if interval has sub-day micros)
6523///   interval ± interval  → interval
6524/// Commutative for `+`. Returns `None` for unrecognised operand pairs so
6525/// the caller can fall through.
6526pub(crate) fn apply_binary_interval(
6527    op: BinOp,
6528    l: &Value,
6529    r: &Value,
6530) -> Result<Option<Value>, EvalError> {
6531    // Normalise so the interval (if any) is always on the right for Add;
6532    // Sub stays left-handed because it isn't commutative.
6533    let (lhs, rhs, sign): (&Value, &Value, i64) = match (l, r, op) {
6534        (Value::Interval { .. }, _, BinOp::Add) => (r, l, 1),
6535        (_, Value::Interval { .. }, BinOp::Add) => (l, r, 1),
6536        (_, Value::Interval { .. }, BinOp::Sub) => (l, r, -1),
6537        _ => return Ok(None),
6538    };
6539    let Value::Interval {
6540        months: rhs_months,
6541        micros: rhs_us,
6542    } = rhs
6543    else {
6544        unreachable!("rhs guaranteed to be Interval by the match above");
6545    };
6546    let signed_months = i64::from(*rhs_months) * sign;
6547    let signed_micros = rhs_us.checked_mul(sign).ok_or(EvalError::TypeMismatch {
6548        detail: "INTERVAL micros overflows on negation".into(),
6549    })?;
6550    match lhs {
6551        Value::Timestamp(t) => Ok(Some(Value::Timestamp(add_interval_to_micros(
6552            *t,
6553            signed_months,
6554            signed_micros,
6555        )?))),
6556        Value::Date(d) => {
6557            // Date + interval stays a date when the interval has zero
6558            // sub-day microseconds; otherwise promote to TIMESTAMP at
6559            // midnight of the (months-shifted) date first.
6560            let day_aligned = signed_micros.rem_euclid(86_400_000_000) == 0;
6561            if day_aligned {
6562                let micros_per_day = 86_400_000_000_i64;
6563                let days_delta = signed_micros / micros_per_day;
6564                let shifted = shift_date_by_months(*d, signed_months)?;
6565                let new_days =
6566                    i64::from(shifted)
6567                        .checked_add(days_delta)
6568                        .ok_or(EvalError::TypeMismatch {
6569                            detail: "DATE ± INTERVAL overflows DATE range".into(),
6570                        })?;
6571                let days32 = i32::try_from(new_days).map_err(|_| EvalError::TypeMismatch {
6572                    detail: "DATE ± INTERVAL overflows DATE range".into(),
6573                })?;
6574                Ok(Some(Value::Date(days32)))
6575            } else {
6576                let base =
6577                    i64::from(*d)
6578                        .checked_mul(86_400_000_000)
6579                        .ok_or(EvalError::TypeMismatch {
6580                            detail: "DATE → TIMESTAMP lift overflows for INTERVAL math".into(),
6581                        })?;
6582                Ok(Some(Value::Timestamp(add_interval_to_micros(
6583                    base,
6584                    signed_months,
6585                    signed_micros,
6586                )?)))
6587            }
6588        }
6589        Value::Interval {
6590            months: lhs_months,
6591            micros: lhs_us,
6592        } => {
6593            let new_months = i64::from(*lhs_months)
6594                .checked_add(signed_months)
6595                .and_then(|n| i32::try_from(n).ok())
6596                .ok_or(EvalError::TypeMismatch {
6597                    detail: "INTERVAL ± INTERVAL months overflows i32".into(),
6598                })?;
6599            let new_micros = lhs_us
6600                .checked_add(signed_micros)
6601                .ok_or(EvalError::TypeMismatch {
6602                    detail: "INTERVAL ± INTERVAL micros overflows i64".into(),
6603                })?;
6604            Ok(Some(Value::Interval {
6605                months: new_months,
6606                micros: new_micros,
6607            }))
6608        }
6609        _ => Err(EvalError::TypeMismatch {
6610            detail: format!(
6611                "operator {op:?} not defined for {:?} and INTERVAL",
6612                lhs.data_type()
6613            ),
6614        }),
6615    }
6616}
6617
6618/// Shift a `Date` by a signed number of months using the PG clamp rule.
6619fn shift_date_by_months(d: i32, months: i64) -> Result<i32, EvalError> {
6620    let (y, m, day) = civil_from_days(d);
6621    let months_i32 = i32::try_from(months).map_err(|_| EvalError::TypeMismatch {
6622        detail: "INTERVAL months delta out of i32 range".into(),
6623    })?;
6624    let (ny, nm, nd) = add_months_to_civil(y, m, day, months_i32);
6625    Ok(days_from_civil(ny, nm, nd))
6626}
6627
6628/// Add (months, micros) to a `Timestamp` (microseconds since epoch).
6629/// Months part is applied through civil calendar with clamp-to-last-day;
6630/// micros part is plain i64 addition with overflow guard.
6631fn add_interval_to_micros(t: i64, months: i64, micros: i64) -> Result<i64, EvalError> {
6632    let mut out = t;
6633    if months != 0 {
6634        const MICROS_PER_DAY: i64 = 86_400_000_000;
6635        let days = out.div_euclid(MICROS_PER_DAY);
6636        let day_micros = out.rem_euclid(MICROS_PER_DAY);
6637        let day_i32 = i32::try_from(days).map_err(|_| EvalError::TypeMismatch {
6638            detail: "TIMESTAMP day component out of i32 range for INTERVAL months math".into(),
6639        })?;
6640        let shifted_days = shift_date_by_months(day_i32, months)?;
6641        out = i64::from(shifted_days)
6642            .checked_mul(MICROS_PER_DAY)
6643            .and_then(|n| n.checked_add(day_micros))
6644            .ok_or(EvalError::TypeMismatch {
6645                detail: "TIMESTAMP ± INTERVAL months overflows i64 microseconds".into(),
6646            })?;
6647    }
6648    out.checked_add(micros).ok_or(EvalError::TypeMismatch {
6649        detail: "TIMESTAMP ± INTERVAL micros overflows i64".into(),
6650    })
6651}
6652
6653/// Dispatch for any binary op when at least one operand is NUMERIC.
6654/// Other-side integers / floats are promoted to a NUMERIC at a common
6655/// scale; all add / sub / mul / div / compare paths stay in i128.
6656#[allow(clippy::needless_pass_by_value)] // mirrors `apply_binary`'s by-value calling convention
6657fn apply_binary_numeric(op: BinOp, l: Value, r: Value) -> Result<Value, EvalError> {
6658    // Float still wins — Numeric + Float coerces both to f64 and runs
6659    // through the float path. PG demotes Numeric to float in this mix
6660    // too (the documented behaviour for `numeric + double precision`).
6661    let float_path = matches!(l, Value::Float(_)) || matches!(r, Value::Float(_));
6662    if float_path {
6663        let af = as_f64(&l)?;
6664        let bf = as_f64(&r)?;
6665        return match op {
6666            BinOp::Add => Ok(Value::Float(af + bf)),
6667            BinOp::Sub => Ok(Value::Float(af - bf)),
6668            BinOp::Mul => Ok(Value::Float(af * bf)),
6669            BinOp::Div => {
6670                if bf == 0.0 {
6671                    Err(EvalError::DivisionByZero)
6672                } else {
6673                    Ok(Value::Float(af / bf))
6674                }
6675            }
6676            BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq => {
6677                let ord = af.partial_cmp(&bf).ok_or(EvalError::TypeMismatch {
6678                    detail: "NaN in NUMERIC/Float comparison".into(),
6679                })?;
6680                Ok(Value::Bool(cmp_to_bool(op, ord)))
6681            }
6682            BinOp::Concat => Ok(text_concat(&l, &r)),
6683            other => Err(EvalError::TypeMismatch {
6684                detail: format!("operator {other:?} not defined for NUMERIC and Float"),
6685            }),
6686        };
6687    }
6688    // Promote integer ↔ numeric to a shared scale (max of both sides).
6689    let (a, sa) = numeric_or_widen(&l).ok_or_else(|| EvalError::TypeMismatch {
6690        detail: format!("NUMERIC op against non-numeric {:?}", l.data_type()),
6691    })?;
6692    let (b, sb) = numeric_or_widen(&r).ok_or_else(|| EvalError::TypeMismatch {
6693        detail: format!("NUMERIC op against non-numeric {:?}", r.data_type()),
6694    })?;
6695    match op {
6696        BinOp::Add | BinOp::Sub => {
6697            let target_scale = sa.max(sb);
6698            let lhs = rescale(a, sa, target_scale).ok_or(EvalError::TypeMismatch {
6699                detail: "NUMERIC overflow on rescale".into(),
6700            })?;
6701            let rhs = rescale(b, sb, target_scale).ok_or(EvalError::TypeMismatch {
6702                detail: "NUMERIC overflow on rescale".into(),
6703            })?;
6704            let r = match op {
6705                BinOp::Add => lhs.checked_add(rhs),
6706                BinOp::Sub => lhs.checked_sub(rhs),
6707                _ => unreachable!(),
6708            }
6709            .ok_or(EvalError::TypeMismatch {
6710                detail: "NUMERIC overflow on +/-".into(),
6711            })?;
6712            Ok(Value::Numeric {
6713                scaled: r,
6714                scale: target_scale,
6715            })
6716        }
6717        BinOp::Mul => {
6718            let scaled = a.checked_mul(b).ok_or(EvalError::TypeMismatch {
6719                detail: "NUMERIC overflow on *".into(),
6720            })?;
6721            Ok(Value::Numeric {
6722                scaled,
6723                scale: sa.saturating_add(sb),
6724            })
6725        }
6726        BinOp::Div => {
6727            if b == 0 {
6728                return Err(EvalError::DivisionByZero);
6729            }
6730            // Result scale: keep the wider operand's scale. Pre-scale
6731            // the numerator so the integer division retains that many
6732            // fractional digits. Round half-away-from-zero.
6733            let target_scale = sa.max(sb);
6734            // Numerator effective scale becomes sa + target_scale; we
6735            // bring it up to (target_scale + sb) so the divisor's scale
6736            // cancels cleanly.
6737            let bump = pow10_i128(target_scale.saturating_add(sb).saturating_sub(sa));
6738            let num = a.checked_mul(bump).ok_or(EvalError::TypeMismatch {
6739                detail: "NUMERIC overflow on / scaling".into(),
6740            })?;
6741            let half = if b >= 0 { b / 2 } else { -(b / 2) };
6742            let adj = if (num >= 0) == (b >= 0) {
6743                num + half
6744            } else {
6745                num - half
6746            };
6747            Ok(Value::Numeric {
6748                scaled: adj / b,
6749                scale: target_scale,
6750            })
6751        }
6752        BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq => {
6753            let target_scale = sa.max(sb);
6754            let lhs = rescale(a, sa, target_scale).ok_or(EvalError::TypeMismatch {
6755                detail: "NUMERIC overflow on rescale".into(),
6756            })?;
6757            let rhs = rescale(b, sb, target_scale).ok_or(EvalError::TypeMismatch {
6758                detail: "NUMERIC overflow on rescale".into(),
6759            })?;
6760            Ok(Value::Bool(cmp_to_bool(op, lhs.cmp(&rhs))))
6761        }
6762        BinOp::Concat => Ok(text_concat(&l, &r)),
6763        other => Err(EvalError::TypeMismatch {
6764            detail: format!("operator {other:?} not defined for NUMERIC"),
6765        }),
6766    }
6767}
6768
6769/// Express `v` as a `(scaled_i128, scale)` pair. Plain integers come
6770/// back with `scale=0`; NUMERIC keeps its own scale. Anything else
6771/// returns `None` and the caller raises a type error.
6772fn numeric_or_widen(v: &Value) -> Option<(i128, u8)> {
6773    match v {
6774        Value::Numeric { scaled, scale } => Some((*scaled, *scale)),
6775        Value::Int(n) => Some((i128::from(*n), 0)),
6776        Value::SmallInt(n) => Some((i128::from(*n), 0)),
6777        Value::BigInt(n) => Some((i128::from(*n), 0)),
6778        _ => None,
6779    }
6780}
6781
6782fn rescale(scaled: i128, src: u8, dst: u8) -> Option<i128> {
6783    if src == dst {
6784        return Some(scaled);
6785    }
6786    if dst > src {
6787        scaled.checked_mul(pow10_i128(dst - src))
6788    } else {
6789        let drop = pow10_i128(src - dst);
6790        let half = drop / 2;
6791        let r = if scaled >= 0 {
6792            scaled + half
6793        } else {
6794            scaled - half
6795        };
6796        Some(r / drop)
6797    }
6798}
6799
6800const fn pow10_i128(p: u8) -> i128 {
6801    let mut acc: i128 = 1;
6802    let mut i = 0;
6803    while i < p {
6804        acc *= 10;
6805        i += 1;
6806    }
6807    acc
6808}
6809
6810const fn cmp_to_bool(op: BinOp, ord: core::cmp::Ordering) -> bool {
6811    use core::cmp::Ordering::{Equal, Greater, Less};
6812    match op {
6813        BinOp::Eq => matches!(ord, Equal),
6814        BinOp::NotEq => !matches!(ord, Equal),
6815        BinOp::Lt => matches!(ord, Less),
6816        BinOp::LtEq => matches!(ord, Less | Equal),
6817        BinOp::Gt => matches!(ord, Greater),
6818        BinOp::GtEq => matches!(ord, Greater | Equal),
6819        _ => false,
6820    }
6821}
6822
6823/// SQL `||` string concatenation. Operands are coerced to text via the same
6824/// rule as `::text` cast. NULL propagates (handled above; this function only
6825/// runs with non-NULL operands).
6826fn text_concat(l: &Value, r: &Value) -> Value {
6827    // v7.11.8 — PG `||` overloads: TEXT[] || TEXT[] = concatenated array;
6828    // TEXT[] || TEXT (or TEXT || TEXT[]) prepends/appends the single
6829    // element. NULL || anything = NULL (PG semantics for arrays;
6830    // text concat treats NULL the same way after value_to_text).
6831    match (l, r) {
6832        (Value::Null, _) | (_, Value::Null) => {
6833            // PG text concat: NULL || x = NULL. Array concat: NULL || x = NULL.
6834            // Keep the legacy text path (value_to_text handles Null as ""),
6835            // but for arrays we surface real NULL to match PG.
6836            if matches!(
6837                l,
6838                Value::TextArray(_) | Value::IntArray(_) | Value::BigIntArray(_) | Value::Bytes(_)
6839            ) || matches!(
6840                r,
6841                Value::TextArray(_) | Value::IntArray(_) | Value::BigIntArray(_) | Value::Bytes(_)
6842            ) {
6843                return Value::Null;
6844            }
6845        }
6846        (Value::TextArray(a), Value::TextArray(b)) => {
6847            let mut out = a.clone();
6848            out.extend(b.iter().cloned());
6849            return Value::TextArray(out);
6850        }
6851        (Value::TextArray(a), Value::Text(s)) => {
6852            let mut out = a.clone();
6853            out.push(Some(s.clone()));
6854            return Value::TextArray(out);
6855        }
6856        (Value::Text(s), Value::TextArray(b)) => {
6857            let mut out: alloc::vec::Vec<Option<alloc::string::String>> =
6858                alloc::vec::Vec::with_capacity(1 + b.len());
6859            out.push(Some(s.clone()));
6860            out.extend(b.iter().cloned());
6861            return Value::TextArray(out);
6862        }
6863        // v7.11.13 — IntArray / BigIntArray `||` overloads. Same
6864        // PG semantics as TEXT[]: array||array concatenates, and
6865        // array||scalar appends/prepends. Mixed Int/BigInt widens
6866        // to BigIntArray.
6867        (Value::IntArray(a), Value::IntArray(b)) => {
6868            let mut out = a.clone();
6869            out.extend(b.iter().copied());
6870            return Value::IntArray(out);
6871        }
6872        (Value::IntArray(a), Value::Int(n)) => {
6873            let mut out = a.clone();
6874            out.push(Some(*n));
6875            return Value::IntArray(out);
6876        }
6877        (Value::IntArray(a), Value::SmallInt(n)) => {
6878            let mut out = a.clone();
6879            out.push(Some(i32::from(*n)));
6880            return Value::IntArray(out);
6881        }
6882        (Value::Int(n), Value::IntArray(b)) => {
6883            let mut out: alloc::vec::Vec<Option<i32>> = alloc::vec::Vec::with_capacity(1 + b.len());
6884            out.push(Some(*n));
6885            out.extend(b.iter().copied());
6886            return Value::IntArray(out);
6887        }
6888        (Value::SmallInt(n), Value::IntArray(b)) => {
6889            let mut out: alloc::vec::Vec<Option<i32>> = alloc::vec::Vec::with_capacity(1 + b.len());
6890            out.push(Some(i32::from(*n)));
6891            out.extend(b.iter().copied());
6892            return Value::IntArray(out);
6893        }
6894        (Value::BigIntArray(a), Value::BigIntArray(b)) => {
6895            let mut out = a.clone();
6896            out.extend(b.iter().copied());
6897            return Value::BigIntArray(out);
6898        }
6899        (Value::BigIntArray(a), Value::IntArray(b)) => {
6900            let mut out = a.clone();
6901            out.extend(b.iter().map(|o| o.map(i64::from)));
6902            return Value::BigIntArray(out);
6903        }
6904        (Value::IntArray(a), Value::BigIntArray(b)) => {
6905            let mut out: alloc::vec::Vec<Option<i64>> =
6906                a.iter().map(|o| o.map(i64::from)).collect();
6907            out.extend(b.iter().copied());
6908            return Value::BigIntArray(out);
6909        }
6910        (Value::BigIntArray(a), Value::BigInt(n)) => {
6911            let mut out = a.clone();
6912            out.push(Some(*n));
6913            return Value::BigIntArray(out);
6914        }
6915        (Value::BigIntArray(a), Value::Int(n)) => {
6916            let mut out = a.clone();
6917            out.push(Some(i64::from(*n)));
6918            return Value::BigIntArray(out);
6919        }
6920        (Value::BigIntArray(a), Value::SmallInt(n)) => {
6921            let mut out = a.clone();
6922            out.push(Some(i64::from(*n)));
6923            return Value::BigIntArray(out);
6924        }
6925        (Value::BigInt(n), Value::BigIntArray(b)) => {
6926            let mut out: alloc::vec::Vec<Option<i64>> = alloc::vec::Vec::with_capacity(1 + b.len());
6927            out.push(Some(*n));
6928            out.extend(b.iter().copied());
6929            return Value::BigIntArray(out);
6930        }
6931        (Value::Int(n), Value::BigIntArray(b)) => {
6932            let mut out: alloc::vec::Vec<Option<i64>> = alloc::vec::Vec::with_capacity(1 + b.len());
6933            out.push(Some(i64::from(*n)));
6934            out.extend(b.iter().copied());
6935            return Value::BigIntArray(out);
6936        }
6937        (Value::SmallInt(n), Value::BigIntArray(b)) => {
6938            let mut out: alloc::vec::Vec<Option<i64>> = alloc::vec::Vec::with_capacity(1 + b.len());
6939            out.push(Some(i64::from(*n)));
6940            out.extend(b.iter().copied());
6941            return Value::BigIntArray(out);
6942        }
6943        // v7.11.15 — BYTEA `||` is byte concatenation.
6944        (Value::Bytes(a), Value::Bytes(b)) => {
6945            let mut out = a.clone();
6946            out.extend_from_slice(b);
6947            return Value::Bytes(out);
6948        }
6949        _ => {}
6950    }
6951    let a = value_to_text(l);
6952    let b = value_to_text(r);
6953    Value::Text(a + &b)
6954}
6955
6956/// pgvector inner-product `<#>`. Returns the *negative* dot product so
6957/// smaller still means more similar — same convention as pgvector.
6958fn inner_product(l: Value, r: Value) -> Result<Value, EvalError> {
6959    let (a, b) = unwrap_vec_pair(l, r, "<#>")?;
6960    let mut dot: f64 = 0.0;
6961    for (x, y) in a.iter().zip(b.iter()) {
6962        dot += f64::from(*x) * f64::from(*y);
6963    }
6964    Ok(Value::Float(-dot))
6965}
6966
6967/// pgvector cosine distance `<=>` — `1 - (a·b) / (‖a‖ ‖b‖)`. A zero-norm
6968/// operand produces NaN (matches pgvector).
6969fn cosine_distance(l: Value, r: Value) -> Result<Value, EvalError> {
6970    let (a, b) = unwrap_vec_pair(l, r, "<=>")?;
6971    let mut dot: f64 = 0.0;
6972    let mut na: f64 = 0.0;
6973    let mut nb: f64 = 0.0;
6974    for (x, y) in a.iter().zip(b.iter()) {
6975        let xf = f64::from(*x);
6976        let yf = f64::from(*y);
6977        dot += xf * yf;
6978        na += xf * xf;
6979        nb += yf * yf;
6980    }
6981    let denom = sqrt_newton(na) * sqrt_newton(nb);
6982    if denom == 0.0 {
6983        return Ok(Value::Float(f64::NAN));
6984    }
6985    Ok(Value::Float(1.0 - dot / denom))
6986}
6987
6988fn unwrap_vec_pair(l: Value, r: Value, op: &str) -> Result<(Vec<f32>, Vec<f32>), EvalError> {
6989    // v6.0.1: SQ8 cells coming through the SQL evaluator are
6990    // dequantised to f32 here so the existing scalar distance
6991    // arithmetic stays intact. HNSW kNN search continues to use
6992    // the asymmetric ADC variant inside `cell_to_query_metric_
6993    // distance` — this path only runs when a vector expression
6994    // lands in the evaluator (full-scan ORDER BY, SELECT
6995    // projection of `v <-> $1`, etc.).
6996    let to_f32 = |v: Value| -> Option<Vec<f32>> {
6997        match v {
6998            Value::Vector(a) => Some(a),
6999            Value::Sq8Vector(q) => Some(spg_storage::quantize::dequantize(&q)),
7000            // v6.0.3: bit-exact dequant for halfvec cells.
7001            Value::HalfVector(h) => Some(h.to_f32_vec()),
7002            _ => None,
7003        }
7004    };
7005    let l_ty = l.data_type();
7006    let r_ty = r.data_type();
7007    match (to_f32(l), to_f32(r)) {
7008        (Some(a), Some(b)) => {
7009            if a.len() != b.len() {
7010                return Err(EvalError::TypeMismatch {
7011                    detail: format!("vector dim mismatch in {op}: {} vs {}", a.len(), b.len()),
7012                });
7013            }
7014            Ok((a, b))
7015        }
7016        _ => Err(EvalError::TypeMismatch {
7017            detail: format!("{op} requires two vectors, got {l_ty:?} and {r_ty:?}"),
7018        }),
7019    }
7020}
7021
7022/// Numeric arithmetic with widening.
7023/// - both `Int` → `Int` (with overflow check)
7024/// - `Int` op `BigInt` (either side) → `BigInt`
7025/// - any `Float` involved → `Float`
7026/// Bitwise integer op (`|` / `&`). PG defines these for integer
7027/// types only — SmallInt widens to Int, Int x BigInt widens to
7028/// BigInt, anything else is a type error (mailrs embed round-12).
7029fn bitop(
7030    l: Value,
7031    r: Value,
7032    f: impl Fn(i64, i64) -> i64,
7033    op_name: &str,
7034) -> Result<Value, EvalError> {
7035    let widen = |v: Value| -> Value {
7036        match v {
7037            Value::SmallInt(n) => Value::Int(i32::from(n)),
7038            other => other,
7039        }
7040    };
7041    match (widen(l), widen(r)) {
7042        (Value::Int(a), Value::Int(b)) => {
7043            let result = f(i64::from(a), i64::from(b));
7044            // Two i32 inputs can't overflow i32 under | / &.
7045            Ok(Value::Int(result as i32))
7046        }
7047        (Value::Int(a), Value::BigInt(b)) | (Value::BigInt(b), Value::Int(a)) => {
7048            Ok(Value::BigInt(f(i64::from(a), b)))
7049        }
7050        (Value::BigInt(a), Value::BigInt(b)) => Ok(Value::BigInt(f(a, b))),
7051        (a, b) => Err(EvalError::TypeMismatch {
7052            detail: format!("cannot apply {op_name} to {a:?} and {b:?}"),
7053        }),
7054    }
7055}
7056
7057fn arith(
7058    l: Value,
7059    r: Value,
7060    int_op: impl Fn(i64, i64) -> Option<i64>,
7061    float_op: impl Fn(f64, f64) -> f64,
7062    op_name: &str,
7063) -> Result<Value, EvalError> {
7064    // Widen SmallInt to Int up front so the rest of the arithmetic
7065    // table only deals with Int / BigInt / Float pairs.
7066    let widen = |v: Value| -> Value {
7067        match v {
7068            Value::SmallInt(n) => Value::Int(i32::from(n)),
7069            other => other,
7070        }
7071    };
7072    let l = widen(l);
7073    let r = widen(r);
7074    match (l, r) {
7075        (Value::Int(a), Value::Int(b)) => {
7076            let result = int_op(i64::from(a), i64::from(b)).ok_or(EvalError::TypeMismatch {
7077                detail: format!("integer overflow on {op_name}"),
7078            })?;
7079            if let Ok(small) = i32::try_from(result) {
7080                Ok(Value::Int(small))
7081            } else {
7082                Ok(Value::BigInt(result))
7083            }
7084        }
7085        (Value::Int(a), Value::BigInt(b)) | (Value::BigInt(b), Value::Int(a)) => {
7086            let result = int_op(i64::from(a), b).ok_or(EvalError::TypeMismatch {
7087                detail: format!("bigint overflow on {op_name}"),
7088            })?;
7089            Ok(Value::BigInt(result))
7090        }
7091        (Value::BigInt(a), Value::BigInt(b)) => {
7092            let result = int_op(a, b).ok_or(EvalError::TypeMismatch {
7093                detail: format!("bigint overflow on {op_name}"),
7094            })?;
7095            Ok(Value::BigInt(result))
7096        }
7097        (a, b)
7098            if a.data_type() == Some(DataType::Float) || b.data_type() == Some(DataType::Float) =>
7099        {
7100            let af = as_f64(&a)?;
7101            let bf = as_f64(&b)?;
7102            Ok(Value::Float(float_op(af, bf)))
7103        }
7104        (a, b) => Err(EvalError::TypeMismatch {
7105            detail: format!(
7106                "{op_name} applied to non-numeric: {:?} vs {:?}",
7107                a.data_type(),
7108                b.data_type()
7109            ),
7110        }),
7111    }
7112}
7113
7114/// L2 (Euclidean) distance between two vectors of equal dimension.
7115/// Returned as `Value::Float(d)` so it composes with the existing
7116/// comparison / sort plumbing. Mismatched dims or non-vector operands
7117/// raise `TypeMismatch`.
7118#[allow(clippy::many_single_char_names)] // l, r, a, b, d are the natural names
7119fn l2_distance(l: Value, r: Value) -> Result<Value, EvalError> {
7120    // v6.0.1: route both operands through `unwrap_vec_pair` so SQ8
7121    // cells dequantise on the way in. Sub-f64 precision loss is
7122    // negligible vs the dequantisation noise the SQ8 path already
7123    // ships with.
7124    let (a, b) = unwrap_vec_pair(l, r, "<->")?;
7125    let mut sum: f64 = 0.0;
7126    for (x, y) in a.iter().zip(b.iter()) {
7127        let d = f64::from(*x) - f64::from(*y);
7128        sum += d * d;
7129    }
7130    Ok(Value::Float(sqrt_newton(sum)))
7131}
7132
7133/// Self-built `sqrt` for `f64` — `std::f64::sqrt` lives in `std`, which the
7134/// engine's `no_std` constraint disallows. Newton-Raphson with a few rounds
7135/// reaches IEEE-754 precision for the inputs we'll see (sum of squares of
7136/// f32-derived distances, always non-negative, never NaN).
7137fn sqrt_newton(x: f64) -> f64 {
7138    if x <= 0.0 {
7139        return 0.0;
7140    }
7141    let mut g = x;
7142    // 10 iterations is conservative; 6 already converges to ulp for typical
7143    // distances.
7144    for _ in 0..10 {
7145        g = 0.5 * (g + x / g);
7146    }
7147    g
7148}
7149
7150fn div_op(l: Value, r: Value) -> Result<Value, EvalError> {
7151    let any_float = matches!(l.data_type(), Some(DataType::Float))
7152        || matches!(r.data_type(), Some(DataType::Float));
7153    if any_float {
7154        let a = as_f64(&l)?;
7155        let b = as_f64(&r)?;
7156        if b == 0.0 {
7157            return Err(EvalError::DivisionByZero);
7158        }
7159        return Ok(Value::Float(a / b));
7160    }
7161    arith(
7162        l,
7163        r,
7164        |a, b| {
7165            if b == 0 { None } else { Some(a / b) }
7166        },
7167        |a, b| a / b,
7168        "/",
7169    )
7170    .map_err(|e| match e {
7171        // The closure returns None on b == 0; translate that into the dedicated
7172        // DivisionByZero variant instead of "integer overflow on /".
7173        EvalError::TypeMismatch { detail } if detail.contains('/') => EvalError::DivisionByZero,
7174        other => other,
7175    })
7176}
7177
7178fn as_f64(v: &Value) -> Result<f64, EvalError> {
7179    match v {
7180        Value::SmallInt(n) => Ok(f64::from(*n)),
7181        Value::Int(n) => Ok(f64::from(*n)),
7182        #[allow(clippy::cast_precision_loss)]
7183        Value::BigInt(n) => Ok(*n as f64),
7184        Value::Float(x) => Ok(*x),
7185        #[allow(clippy::cast_precision_loss)]
7186        Value::Numeric { scaled, scale } => {
7187            let mut div = 1.0_f64;
7188            for _ in 0..*scale {
7189                div *= 10.0;
7190            }
7191            Ok((*scaled as f64) / div)
7192        }
7193        other => Err(EvalError::TypeMismatch {
7194            detail: format!("cannot convert {:?} to FLOAT", other.data_type()),
7195        }),
7196    }
7197}
7198
7199fn compare(op: BinOp, l: &Value, r: &Value) -> Result<Value, EvalError> {
7200    let ord = match (l, r) {
7201        (Value::Int(a), Value::Int(b)) => i64::from(*a).cmp(&i64::from(*b)),
7202        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
7203        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
7204        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
7205        (a, b)
7206            if matches!(a.data_type(), Some(DataType::Float))
7207                || matches!(b.data_type(), Some(DataType::Float)) =>
7208        {
7209            let af = as_f64(a)?;
7210            let bf = as_f64(b)?;
7211            af.partial_cmp(&bf).ok_or(EvalError::TypeMismatch {
7212                detail: "NaN in comparison".into(),
7213            })?
7214        }
7215        (Value::Text(a), Value::Text(b)) => a.cmp(b),
7216        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
7217        // Date / Timestamp compare on their integer storage repr.
7218        // Cross-domain (Date vs Timestamp) lifts the Date to the
7219        // matching midnight TIMESTAMP first.
7220        (Value::Date(a), Value::Date(b)) => a.cmp(b),
7221        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
7222        (Value::Date(a), Value::Timestamp(b)) => (i64::from(*a) * 86_400_000_000).cmp(b),
7223        (Value::Timestamp(a), Value::Date(b)) => a.cmp(&(i64::from(*b) * 86_400_000_000)),
7224        // PG-style implicit coercion: comparing a DATE / TIMESTAMP
7225        // column against a text literal lifts the literal into the
7226        // matching domain (e.g. `day >= '2024-01-01'`).
7227        (Value::Date(a), Value::Text(b)) => {
7228            let bd = parse_date_literal(b).ok_or_else(|| EvalError::TypeMismatch {
7229                detail: format!("cannot parse {b:?} as DATE for comparison"),
7230            })?;
7231            a.cmp(&bd)
7232        }
7233        (Value::Text(a), Value::Date(b)) => {
7234            let ad = parse_date_literal(a).ok_or_else(|| EvalError::TypeMismatch {
7235                detail: format!("cannot parse {a:?} as DATE for comparison"),
7236            })?;
7237            ad.cmp(b)
7238        }
7239        (Value::Timestamp(a), Value::Text(b)) => {
7240            let bt = parse_timestamp_literal(b).ok_or_else(|| EvalError::TypeMismatch {
7241                detail: format!("cannot parse {b:?} as TIMESTAMP for comparison"),
7242            })?;
7243            a.cmp(&bt)
7244        }
7245        (Value::Text(a), Value::Timestamp(b)) => {
7246            let at = parse_timestamp_literal(a).ok_or_else(|| EvalError::TypeMismatch {
7247                detail: format!("cannot parse {a:?} as TIMESTAMP for comparison"),
7248            })?;
7249            at.cmp(b)
7250        }
7251        // v7.17.0 — UUID byte-wise comparison; both sides UUID.
7252        (Value::Uuid(a), Value::Uuid(b)) => a.cmp(b),
7253        // v7.17.0 — PG promotes a `text` literal compared against a
7254        // `uuid` column into uuid (unknown-type literal inference).
7255        // Without this, `WHERE id = '550e...'` falls through to the
7256        // generic TypeMismatch — the application's literal becomes
7257        // an error rather than a comparison.
7258        (Value::Uuid(a), Value::Text(b)) => {
7259            let bu = spg_storage::parse_uuid_str(b).ok_or_else(|| EvalError::TypeMismatch {
7260                detail: format!("invalid input syntax for type uuid: {b:?}"),
7261            })?;
7262            a.cmp(&bu)
7263        }
7264        (Value::Text(a), Value::Uuid(b)) => {
7265            let au = spg_storage::parse_uuid_str(a).ok_or_else(|| EvalError::TypeMismatch {
7266                detail: format!("invalid input syntax for type uuid: {a:?}"),
7267            })?;
7268            au.cmp(b)
7269        }
7270        (a, b) => {
7271            return Err(EvalError::TypeMismatch {
7272                detail: format!(
7273                    "comparison between {:?} and {:?}",
7274                    a.data_type(),
7275                    b.data_type()
7276                ),
7277            });
7278        }
7279    };
7280    let result = match op {
7281        BinOp::Eq => ord.is_eq(),
7282        BinOp::NotEq => !ord.is_eq(),
7283        BinOp::Lt => ord.is_lt(),
7284        BinOp::LtEq => ord.is_le(),
7285        BinOp::Gt => ord.is_gt(),
7286        BinOp::GtEq => ord.is_ge(),
7287        BinOp::And
7288        | BinOp::Or
7289        | BinOp::BitOr
7290        | BinOp::BitAnd
7291        | BinOp::Add
7292        | BinOp::Sub
7293        | BinOp::Mul
7294        | BinOp::Div
7295        | BinOp::L2Distance
7296        | BinOp::InnerProduct
7297        | BinOp::CosineDistance
7298        | BinOp::Concat
7299        | BinOp::JsonGet
7300        | BinOp::JsonGetText
7301        | BinOp::JsonGetPath
7302        | BinOp::JsonGetPathText
7303        | BinOp::JsonContains
7304        | BinOp::TsMatch
7305        | BinOp::IsDistinctFrom
7306        | BinOp::IsNotDistinctFrom
7307        | BinOp::InetContainedBy
7308        | BinOp::InetContainedByEq
7309        | BinOp::InetContains
7310        | BinOp::InetContainsEq
7311        | BinOp::InetOverlap => {
7312            unreachable!("compare() only called with comparison ops")
7313        }
7314    };
7315    Ok(Value::Bool(result))
7316}
7317
7318// SQL three-valued AND / OR.
7319fn and_3vl(l: Value, r: Value) -> Result<Value, EvalError> {
7320    match (l, r) {
7321        (Value::Bool(false), _) | (_, Value::Bool(false)) => Ok(Value::Bool(false)),
7322        (Value::Bool(true), Value::Bool(true)) => Ok(Value::Bool(true)),
7323        (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
7324        (a, b) => Err(EvalError::TypeMismatch {
7325            detail: format!(
7326                "AND on non-boolean: {:?} and {:?}",
7327                a.data_type(),
7328                b.data_type()
7329            ),
7330        }),
7331    }
7332}
7333
7334fn or_3vl(l: Value, r: Value) -> Result<Value, EvalError> {
7335    match (l, r) {
7336        (Value::Bool(true), _) | (_, Value::Bool(true)) => Ok(Value::Bool(true)),
7337        (Value::Bool(false), Value::Bool(false)) => Ok(Value::Bool(false)),
7338        (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
7339        (a, b) => Err(EvalError::TypeMismatch {
7340            detail: format!(
7341                "OR on non-boolean: {:?} and {:?}",
7342                a.data_type(),
7343                b.data_type()
7344            ),
7345        }),
7346    }
7347}
7348
7349#[cfg(test)]
7350mod tests {
7351    use super::*;
7352    use alloc::vec;
7353    use spg_storage::{ColumnSchema, Row};
7354
7355    fn col(name: &str, ty: DataType) -> ColumnSchema {
7356        ColumnSchema::new(name, ty, true)
7357    }
7358
7359    fn ctx<'a>(cols: &'a [ColumnSchema], alias: Option<&'a str>) -> EvalContext<'a> {
7360        EvalContext::new(cols, alias)
7361    }
7362
7363    fn lit(n: i64) -> Expr {
7364        Expr::Literal(Literal::Integer(n))
7365    }
7366
7367    fn null() -> Expr {
7368        Expr::Literal(Literal::Null)
7369    }
7370
7371    fn col_ref(name: &str) -> Expr {
7372        Expr::Column(ColumnName {
7373            qualifier: None,
7374            name: name.into(),
7375        })
7376    }
7377
7378    #[test]
7379    fn literal_evaluates_to_value() {
7380        let r = Row::new(vec![]);
7381        let cs: [ColumnSchema; 0] = [];
7382        let c = ctx(&cs, None);
7383        assert_eq!(eval_expr(&lit(42), &r, &c).unwrap(), Value::Int(42));
7384        assert_eq!(
7385            eval_expr(&Expr::Literal(Literal::Float(1.5)), &r, &c).unwrap(),
7386            Value::Float(1.5)
7387        );
7388        assert_eq!(eval_expr(&null(), &r, &c).unwrap(), Value::Null);
7389    }
7390
7391    #[test]
7392    fn column_lookup_unqualified() {
7393        let cs = vec![col("a", DataType::Int), col("b", DataType::Text)];
7394        let r = Row::new(vec![Value::Int(7), Value::Text("hi".into())]);
7395        let c = ctx(&cs, None);
7396        assert_eq!(eval_expr(&col_ref("a"), &r, &c).unwrap(), Value::Int(7));
7397        assert_eq!(
7398            eval_expr(&col_ref("b"), &r, &c).unwrap(),
7399            Value::Text("hi".into())
7400        );
7401    }
7402
7403    #[test]
7404    fn column_not_found_errors() {
7405        let cs = vec![col("a", DataType::Int)];
7406        let r = Row::new(vec![Value::Int(0)]);
7407        let c = ctx(&cs, None);
7408        let err = eval_expr(&col_ref("ghost"), &r, &c).unwrap_err();
7409        assert!(matches!(err, EvalError::ColumnNotFound { ref name } if name == "ghost"));
7410    }
7411
7412    #[test]
7413    fn qualified_column_matches_alias() {
7414        let cs = vec![col("a", DataType::Int)];
7415        let r = Row::new(vec![Value::Int(5)]);
7416        let c = ctx(&cs, Some("u"));
7417        let qualified = Expr::Column(ColumnName {
7418            qualifier: Some("u".into()),
7419            name: "a".into(),
7420        });
7421        assert_eq!(eval_expr(&qualified, &r, &c).unwrap(), Value::Int(5));
7422    }
7423
7424    #[test]
7425    fn qualified_column_unknown_alias_errors() {
7426        let cs = vec![col("a", DataType::Int)];
7427        let r = Row::new(vec![Value::Int(5)]);
7428        let c = ctx(&cs, Some("u"));
7429        let wrong = Expr::Column(ColumnName {
7430            qualifier: Some("x".into()),
7431            name: "a".into(),
7432        });
7433        assert!(matches!(
7434            eval_expr(&wrong, &r, &c).unwrap_err(),
7435            EvalError::UnknownQualifier { .. }
7436        ));
7437    }
7438
7439    #[test]
7440    fn arithmetic_with_widening() {
7441        let r = Row::new(vec![]);
7442        let cs: [ColumnSchema; 0] = [];
7443        let c = ctx(&cs, None);
7444        let e = Expr::Binary {
7445            lhs: alloc::boxed::Box::new(lit(2)),
7446            op: BinOp::Add,
7447            rhs: alloc::boxed::Box::new(Expr::Literal(Literal::Float(0.5))),
7448        };
7449        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Float(2.5));
7450    }
7451
7452    #[test]
7453    fn division_by_zero_errors() {
7454        let r = Row::new(vec![]);
7455        let cs: [ColumnSchema; 0] = [];
7456        let c = ctx(&cs, None);
7457        let e = Expr::Binary {
7458            lhs: alloc::boxed::Box::new(lit(1)),
7459            op: BinOp::Div,
7460            rhs: alloc::boxed::Box::new(lit(0)),
7461        };
7462        assert_eq!(
7463            eval_expr(&e, &r, &c).unwrap_err(),
7464            EvalError::DivisionByZero
7465        );
7466    }
7467
7468    #[test]
7469    fn comparison_returns_bool() {
7470        let r = Row::new(vec![]);
7471        let cs: [ColumnSchema; 0] = [];
7472        let c = ctx(&cs, None);
7473        let e = Expr::Binary {
7474            lhs: alloc::boxed::Box::new(lit(1)),
7475            op: BinOp::Lt,
7476            rhs: alloc::boxed::Box::new(lit(2)),
7477        };
7478        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Bool(true));
7479    }
7480
7481    #[test]
7482    fn null_propagates_through_arithmetic() {
7483        let r = Row::new(vec![]);
7484        let cs: [ColumnSchema; 0] = [];
7485        let c = ctx(&cs, None);
7486        let e = Expr::Binary {
7487            lhs: alloc::boxed::Box::new(lit(1)),
7488            op: BinOp::Add,
7489            rhs: alloc::boxed::Box::new(null()),
7490        };
7491        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Null);
7492    }
7493
7494    #[test]
7495    fn and_three_valued_logic() {
7496        let r = Row::new(vec![]);
7497        let cs: [ColumnSchema; 0] = [];
7498        let c = ctx(&cs, None);
7499        let tt = |a: bool, b_null: bool| Expr::Binary {
7500            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(a))),
7501            op: BinOp::And,
7502            rhs: alloc::boxed::Box::new(if b_null {
7503                null()
7504            } else {
7505                Expr::Literal(Literal::Bool(true))
7506            }),
7507        };
7508        // FALSE AND NULL → FALSE
7509        assert_eq!(
7510            eval_expr(&tt(false, true), &r, &c).unwrap(),
7511            Value::Bool(false)
7512        );
7513        // TRUE AND NULL → NULL
7514        assert_eq!(eval_expr(&tt(true, true), &r, &c).unwrap(), Value::Null);
7515        // TRUE AND TRUE → TRUE
7516        assert_eq!(
7517            eval_expr(&tt(true, false), &r, &c).unwrap(),
7518            Value::Bool(true)
7519        );
7520    }
7521
7522    #[test]
7523    fn or_three_valued_logic() {
7524        let r = Row::new(vec![]);
7525        let cs: [ColumnSchema; 0] = [];
7526        let c = ctx(&cs, None);
7527        let or_with_null = |a: bool| Expr::Binary {
7528            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(a))),
7529            op: BinOp::Or,
7530            rhs: alloc::boxed::Box::new(null()),
7531        };
7532        // TRUE OR NULL → TRUE
7533        assert_eq!(
7534            eval_expr(&or_with_null(true), &r, &c).unwrap(),
7535            Value::Bool(true)
7536        );
7537        // FALSE OR NULL → NULL
7538        assert_eq!(
7539            eval_expr(&or_with_null(false), &r, &c).unwrap(),
7540            Value::Null
7541        );
7542    }
7543
7544    #[test]
7545    fn not_on_null_is_null() {
7546        let r = Row::new(vec![]);
7547        let cs: [ColumnSchema; 0] = [];
7548        let c = ctx(&cs, None);
7549        let e = Expr::Unary {
7550            op: UnOp::Not,
7551            expr: alloc::boxed::Box::new(null()),
7552        };
7553        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Null);
7554    }
7555
7556    #[test]
7557    fn text_comparison_lexicographic() {
7558        let r = Row::new(vec![]);
7559        let cs: [ColumnSchema; 0] = [];
7560        let c = ctx(&cs, None);
7561        let e = Expr::Binary {
7562            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::String("apple".into()))),
7563            op: BinOp::Lt,
7564            rhs: alloc::boxed::Box::new(Expr::Literal(Literal::String("banana".into()))),
7565        };
7566        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Bool(true));
7567    }
7568
7569    #[test]
7570    fn interval_format_basics() {
7571        assert_eq!(format_interval(0, 0), "0");
7572        assert_eq!(format_interval(0, 86_400_000_000), "1 day");
7573        assert_eq!(format_interval(0, -86_400_000_000), "-1 days");
7574        assert_eq!(format_interval(0, 3_600_000_000), "01:00:00");
7575        assert_eq!(
7576            format_interval(0, 86_400_000_000 + 9_000_000),
7577            "1 day 00:00:09"
7578        );
7579        assert_eq!(format_interval(14, 0), "1 year 2 mons");
7580        assert_eq!(format_interval(-1, 0), "-1 mons");
7581    }
7582
7583    #[test]
7584    fn interval_add_to_timestamp_micros_part() {
7585        // 2024-01-01 00:00:00 + INTERVAL '1 hour' = 2024-01-01 01:00:00
7586        let ts = i64::from(days_from_civil(2024, 1, 1)) * 86_400_000_000;
7587        let r = add_interval_to_micros(ts, 0, 3_600_000_000).unwrap();
7588        let expected = ts + 3_600_000_000;
7589        assert_eq!(r, expected);
7590    }
7591
7592    #[test]
7593    fn interval_clamp_month_end() {
7594        // 2024-01-31 + 1 month = 2024-02-29 (leap year).
7595        let d = days_from_civil(2024, 1, 31);
7596        let shifted = shift_date_by_months(d, 1).unwrap();
7597        let (y, m, day) = civil_from_days(shifted);
7598        assert_eq!((y, m, day), (2024, 2, 29));
7599        // 2023-01-31 + 1 month = 2023-02-28 (non-leap).
7600        let d = days_from_civil(2023, 1, 31);
7601        let shifted = shift_date_by_months(d, 1).unwrap();
7602        let (y, m, day) = civil_from_days(shifted);
7603        assert_eq!((y, m, day), (2023, 2, 28));
7604        // 2024-03-31 - 1 month = 2024-02-29.
7605        let d = days_from_civil(2024, 3, 31);
7606        let shifted = shift_date_by_months(d, -1).unwrap();
7607        let (y, m, day) = civil_from_days(shifted);
7608        assert_eq!((y, m, day), (2024, 2, 29));
7609    }
7610
7611    #[test]
7612    fn interval_date_plus_pure_days_stays_date() {
7613        // DATE + INTERVAL '7 days' must stay DATE.
7614        let d = days_from_civil(2024, 6, 1);
7615        let lhs = Value::Date(d);
7616        let rhs = Value::Interval {
7617            months: 0,
7618            micros: 7 * 86_400_000_000,
7619        };
7620        let v = apply_binary_interval(BinOp::Add, &lhs, &rhs)
7621            .unwrap()
7622            .unwrap();
7623        let expected = days_from_civil(2024, 6, 8);
7624        assert_eq!(v, Value::Date(expected));
7625    }
7626
7627    #[test]
7628    fn interval_date_plus_sub_day_lifts_to_timestamp() {
7629        // DATE + INTERVAL '1 hour' must lift to TIMESTAMP.
7630        let d = days_from_civil(2024, 6, 1);
7631        let lhs = Value::Date(d);
7632        let rhs = Value::Interval {
7633            months: 0,
7634            micros: 3_600_000_000,
7635        };
7636        let v = apply_binary_interval(BinOp::Add, &lhs, &rhs)
7637            .unwrap()
7638            .unwrap();
7639        let expected = i64::from(d) * 86_400_000_000 + 3_600_000_000;
7640        assert_eq!(v, Value::Timestamp(expected));
7641    }
7642}