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