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