Skip to main content

spg_engine/
eval.rs

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