Skip to main content

spg_engine/
eval.rs

1//! Expression evaluator. Given a parsed `Expr`, a `Row`, and the row's column
2//! schema, produce a `Value`. v0.4 implements:
3//!
4//! - literals
5//! - column lookups (bare and qualified `t.col`)
6//! - unary minus / NOT
7//! - binary arithmetic, comparison, AND, OR
8//! - numeric widening (`Int → BigInt → Float`) at evaluation time
9//! - SQL three-valued logic for NULL:
10//!     * any arithmetic / comparison op with a NULL operand → NULL
11//!     * `TRUE OR NULL` → TRUE, `FALSE OR NULL` → NULL,
12//!     * `FALSE AND NULL` → FALSE, `TRUE AND NULL` → NULL,
13//!     * `NOT NULL` → NULL
14//!
15//! v0.4 deliberately does *not* implement: function calls, string
16//! concatenation, IS NULL / IS NOT NULL, BETWEEN, IN, etc. Those come later.
17
18use alloc::borrow::Cow;
19use alloc::format;
20use alloc::string::{String, ToString};
21use alloc::vec::Vec;
22
23use spg_sql::ast::{BinOp, CastTarget, ColumnName, Expr, Literal};
24use spg_storage::{ColumnSchema, Row, Value};
25
26pub(crate) mod binop;
27mod cast;
28pub mod compiled;
29mod datetime;
30mod encoding;
31mod encodings;
32mod format;
33pub(crate) mod functions;
34mod inet;
35pub(crate) mod math;
36mod regexp;
37mod resolve;
38mod strings;
39mod textsearch;
40pub(crate) mod values;
41
42pub use crate::conversions::format_money_array;
43pub(crate) use binop::{
44    add_interval_to_micros, and_3vl, apply_binary, apply_binary_by_ref, apply_binary_interval,
45};
46use binop::{apply_binary_in, apply_unary, compare, pow10_i128};
47pub use cast::{cast_to_vector, cast_value, parse_vector_text};
48pub(crate) use compiled::{
49    CompiledExpr, compile_column_pos, compile_expr, eval_compiled, eval_compiled_ref,
50    fully_compilable,
51};
52use datetime::{
53    age, date_format_mysql, date_part, date_trunc, extract_field, from_unixtime, unix_timestamp_of,
54};
55use encoding::{decode_text, encode_text};
56pub use format::{
57    days_from_civil, format_bigint_array, format_bool_array, format_bytea_array, format_bytea_hex,
58    format_date, format_date_array, format_float, format_float_array, format_int_array,
59    format_interval, format_interval_array, format_money, format_numeric, format_numeric_array,
60    format_numeric_kind, format_real, format_smallint_array, format_text_array, format_time,
61    format_timestamp, format_timestamp_array, format_timestamptz, format_timestamptz_at,
62    format_timetz, format_uuid_array, parse_date_literal, parse_timestamp_literal,
63};
64// v7.39 (GUC knife 3) — session render styles + styled formatters.
65pub use format::{
66    DateOrder, DateStyleKind, IntervalStyleKind, RenderStyle, format_date_array_styled,
67    format_date_styled, format_float_array_styled, format_float_styled,
68    format_interval_array_styled, format_interval_styled, format_real_styled,
69    format_timestamp_array_styled, format_timestamp_styled, format_timestamptz_styled,
70    format_timestamptz_tz, parse_date_literal_ordered, parse_timestamp_literal_ordered,
71    parse_timestamp_literal_tz_ordered,
72};
73use functions::apply_function;
74use inet::{inet_host, inet_masklen, inet_network, inet_op_bool_result};
75pub(crate) use math::{f64_ceil, f64_floor, f64_sqrt};
76use math::{
77    f64_exp, f64_ln, f64_powi, f64_round_half_away, f64_trunc, prng_next_f64, prng_next_u64,
78};
79pub(crate) use regexp::{
80    CompiledRe, compile_re, compiled_is_match, regex_is_match, regexp_matches_rows,
81};
82use regexp::{regexp_matches, regexp_replace, regexp_split_to_array};
83use resolve::{
84    collation_fold_for_compare, compare_is_case_insensitive, composite_eq, eval_expr_cow,
85    is_owned_compare_value, resolve_column, resolve_column_borrowed, text_prefix_chars,
86};
87pub(crate) use resolve::{
88    column_at, column_collation, find_column_pos, is_binary_coerced, locate_column,
89};
90use strings::{
91    TrimSide, format_string, pg_quote_ident, pg_quote_literal, pg_typeof_name, string_left_right,
92    string_pad, string_trim, to_char, value_to_format_text,
93};
94pub use textsearch::{
95    decode_tsquery_external, decode_tsvector_external, format_tsquery, format_tsvector,
96};
97use textsearch::{
98    fts_phraseto_tsquery, fts_plainto_tsquery, fts_setweight, fts_to_tsquery, fts_to_tsvector,
99    fts_ts_headline, fts_ts_rank, fts_ts_rank_cd, fts_ts_rewrite, fts_tsquery_bool,
100    fts_websearch_to_tsquery, ts_match, tsvector_concat,
101};
102pub use values::gen_random_uuid_bytes;
103/// v7.39 (tz epic) — fixed-offset / abbreviation resolution, exposed
104/// for `SET timezone` validation (named zones go through the host tzdb).
105pub(crate) fn datetime_resolve_zone_offset(z: &str) -> Option<i64> {
106    datetime::resolve_zone_offset(z)
107}
108
109pub use values::value_to_text;
110pub use values::value_to_text_styled;
111pub use values::value_to_text_with_fsp;
112use values::{
113    array_2d_dims, array_element_at, array_len, array_rebuild, value_cmp_for_min_max, value_to_f64,
114    values_equal_for_nullif,
115};
116
117/// Resolution context for evaluating a single row. `table_alias` is the alias
118/// (or table name) callers should accept as the qualifier on a column ref —
119/// e.g. `FROM users AS u` makes `u.name` valid and rejects `other.name`.
120#[derive(Clone)]
121#[allow(missing_debug_implementations)] // sequence_resolver is a dyn Fn — no Debug
122pub struct EvalContext<'a> {
123    pub columns: &'a [ColumnSchema],
124    pub table_alias: Option<&'a str>,
125    /// v6.1.1 — bound parameters for `$N` placeholders inside the
126    /// expression tree. Empty for simple queries; populated by the
127    /// prepared-statement Execute path with Bind values converted
128    /// to `Value`. Index N (1-based per PG) hits `params[N-1]`.
129    pub params: &'a [Value<'static>],
130    /// v7.12.1 — session text-search config (from `SET
131    /// default_text_search_config = '<name>'`). Resolved when the
132    /// engine builds an `EvalContext` and consumed by the FTS
133    /// function dispatcher when `to_tsvector(text)` /
134    /// `plainto_tsquery(text)` etc are called without an explicit
135    /// config arg. `None` falls through to `simple`.
136    pub default_text_search_config: Option<&'a str>,
137    /// v7.17.0 Phase 1.1 — `nextval` / `currval` / `setval`
138    /// resolver. The engine builds this around a `&mut Catalog`
139    /// so apply_function can mutate sequence state without
140    /// eval owning a catalog reference. When `None`, sequence
141    /// functions return an error (read-only contexts).
142    pub sequence_resolver: Option<&'a SequenceResolver<'a>>,
143    /// v7.37.16 (16.12) — read-only catalog reference for
144    /// builtins that need catalog walks (e.g. `pg_partition_root`,
145    /// `pg_partition_ancestors`). `None` falls through to the
146    /// "no catalog available" branch which returns NULL — same
147    /// shape PG returns for a non-existent OID. Most evaluation
148    /// sites don't need catalog access (row scans, projections);
149    /// they construct contexts with `catalog: None` and the
150    /// engine populates `Some(&self.catalog)` only at the engine's
151    /// top-level entry points where the borrow is unambiguous.
152    pub catalog: Option<&'a spg_storage::Catalog>,
153    /// v7.39 (round 346, M1) — is this a MySQL-dialect session? The two
154    /// dialects disagree about what counts as a truth value: MariaDB
155    /// takes any non-zero number (and a string's leading number) as
156    /// true, PG refuses anything that is not boolean. Set from the
157    /// engine by [`EvalContext::with_engine`]; a context built without
158    /// one keeps PG's stricter reading.
159    pub mysql_dialect: bool,
160    /// Session GUCs set via `SET name = value` / `set_config`, keyed by
161    /// lowercased name. `current_setting('app.foo')` reads custom
162    /// (namespaced) settings from here — the mechanism apps use for
163    /// request context / RLS. `None` in read-only contexts that have no
164    /// session; unknown names then fall through to PG defaults.
165    pub session_gucs: Option<&'a alloc::collections::BTreeMap<String, String>>,
166    /// v7.39 (read01 round 58) — the engine's role store, so
167    /// `has_table_privilege('bob', …)` can expand bob's role MEMBERSHIPS (a
168    /// grant to a group role answers `true` for its inheriting members). `None`
169    /// in a context with no engine behind it — the role then stands alone.
170    pub users: Option<&'a crate::users::UserStore>,
171    /// v7.39 (read01 round 61) — how deep we are inside USER-DEFINED function
172    /// bodies. A function's body is evaluated with a child context, and a body
173    /// may call another function, so this bounds the recursion (a function that
174    /// calls itself would otherwise blow the stack, which an embed host cannot
175    /// catch).
176    pub fn_depth: u16,
177    /// v7.39 (read01 round 63) — the ENGINE, for a user-function body that has
178    /// its own FROM (`SELECT v FROM t WHERE id = k`). Such a body has to run
179    /// through the real executor: reading `catalog`'s rows straight from eval
180    /// would bypass the row-header visibility filter, so under in-place MVCC a
181    /// function would happily read DEAD rows. `None` in a context with no
182    /// engine behind it — a body with a FROM then errors, saying so.
183    pub engine: Option<&'a crate::Engine>,
184    /// v7.38 (read01 U15) — per-scan deterministic sampler state for
185    /// `TABLESAMPLE … REPEATABLE(seed)`. A fresh cell is created before a
186    /// scan whose predicate may draw `__tsm_fract(seed)`; the cell holds
187    /// `None` until the first draw seeds it from that literal, then a
188    /// scan-local xorshift sequence (isolated from the process-global
189    /// `random()` PRNG, so it's deterministic and rescan-stable). `None`
190    /// here means no sampler is attached.
191    pub sample_rng: Option<&'a core::cell::Cell<Option<u64>>>,
192    /// v7.38 (read01 P3.25) — native-stack-overflow guard. Lazily seeded
193    /// with the stack pointer of the outermost `eval_expr` call; deeper
194    /// calls compare their own pointer against it and bail with
195    /// [`EvalError::StackDepthExceeded`] once usage crosses a safe margin,
196    /// so a pathologically nested expression errors instead of aborting the
197    /// process. Owned (not a borrowed cell) so it stays stack-local and
198    /// never touches `Engine`'s `Sync` bound.
199    pub recursion_base: core::cell::Cell<usize>,
200    /// v7.39 (GUC knife 3) — session render style (DateStyle /
201    /// IntervalStyle / extra_float_digits) for text output produced
202    /// inside expression evaluation (`::text` casts). Contexts built
203    /// away from the session (per-shard scan filters, index probes)
204    /// keep the default — they don't render text output.
205    pub render_style: crate::eval::format::RenderStyle,
206    /// v7.39 (tz epic) — host IANA timezone lookups for named zones
207    /// (session rendering, AT TIME ZONE, literal zone suffixes).
208    pub tz_offset_fn: Option<crate::TzOffsetFn>,
209    pub tz_localize_fn: Option<crate::TzLocalizeFn>,
210    pub tz_abbrev_fn: Option<crate::TzAbbrevFn>,
211    /// v7.38 (read01 P5.24) — host-provided CSPRNG (the server injects
212    /// `/dev/urandom`). Cryptographic builtins (`gen_random_bytes`,
213    /// `gen_salt`) draw from this instead of the process-static xorshift
214    /// PRNG, so their output isn't predictable. `None` (no host CSPRNG)
215    /// falls back to the PRNG — fine for the non-cryptographic `random()`.
216    pub salt_fn: Option<crate::SaltFn>,
217    /// v7.39 (read01 pgstatfuncs.c) — calling-connection identity for
218    /// pg_backend_pid(); `None` (embedded / detached contexts) → 1.
219    pub backend_pid_fn: Option<crate::BackendPidFn>,
220    /// v7.39 (round 476) — the WAL byte position, for the LSN functions.
221    pub wal_lsn_fn: Option<crate::WalLsnFn>,
222    /// v7.39 (round 318, V51) — host connection-control hook for
223    /// `pg_cancel_backend` / `pg_terminate_backend`. `None` (embedded /
224    /// detached contexts) ⇒ there is nothing to signal, so they answer
225    /// false rather than pretending the signal landed.
226    pub backend_signal_fn: Option<crate::BackendSignalFn>,
227    /// v7.38 (read01 P6.08) — host wall clock (µs since Unix epoch). `uuidv7`
228    /// uses it for the real time-ordered 48-bit millisecond prefix; `None`
229    /// (no host clock) falls back to the deterministic anchor.
230    pub clock: Option<crate::ClockFn>,
231    /// v7.38 (T24) — read-only view of the engine's transaction-version state,
232    /// so the `txid_*` / `pg_*_xact_id` / `pg_xact_status` builtins report the
233    /// real transaction ids instead of a constant stub. `None` on the scan /
234    /// join / aggregate contexts that never evaluate them.
235    pub xact: Option<XactView<'a>>,
236    /// v7.38 (T24) — PG's `txid_current()` ASSIGNS an id to a transaction that
237    /// has none. In autocommit a read-only statement has no writer version, so
238    /// the first call allocates one here and later calls in the same statement
239    /// reuse it — `SELECT txid_current(), txid_current()` must agree, as in PG.
240    pub assigned_xid: core::cell::Cell<Option<u64>>,
241}
242
243/// v7.38 (T24) — the transaction-id surface PG's `txid_*` family exposes.
244/// SPG's writer versions ARE its transaction ids (`row_header::next_version`),
245/// so no separate xid counter is needed — this is the bridge U22 was waiting
246/// on.
247#[derive(Clone, Copy, Debug)]
248pub struct XactView<'a> {
249    /// The id assigned to the current transaction (allocated at BEGIN) or, in
250    /// autocommit, to the current statement once it has written. `None` when
251    /// nothing has been assigned — `*_if_assigned` returns NULL there, as PG does.
252    pub current: Option<u64>,
253    /// Ids allocated by transactions that have neither committed nor aborted.
254    pub active: &'a alloc::collections::BTreeSet<u64>,
255    /// Ids of rolled-back transactions.
256    pub aborted: &'a alloc::collections::BTreeSet<u64>,
257}
258
259/// v7.17.0 — sequence-mutating callback used by `apply_function`
260/// for `nextval` / `currval` / `setval`. Implemented by the
261/// engine to thread `&mut Catalog` access through an immutable
262/// `&EvalContext`.
263pub type SequenceResolver<'a> = dyn Fn(SequenceOp) -> Result<i64, EvalError> + 'a;
264
265/// v7.17.0 — sequence operation requested by an Expr eval.
266#[derive(Debug, Clone)]
267pub enum SequenceOp {
268    Next(String),
269    Curr(String),
270    Set {
271        name: String,
272        value: i64,
273        is_called: bool,
274    },
275}
276
277impl<'a> EvalContext<'a> {
278    pub const fn new(columns: &'a [ColumnSchema], table_alias: Option<&'a str>) -> Self {
279        Self {
280            columns,
281            table_alias,
282            params: &[],
283            default_text_search_config: None,
284            sequence_resolver: None,
285            catalog: None,
286            mysql_dialect: false,
287            session_gucs: None,
288            users: None,
289            fn_depth: 0,
290            engine: None,
291            sample_rng: None,
292            recursion_base: core::cell::Cell::new(0),
293            render_style: crate::eval::format::RenderStyle {
294                date_style: crate::eval::format::DateStyleKind::Iso,
295                date_order: crate::eval::format::DateOrder::Mdy,
296                interval_style: crate::eval::format::IntervalStyleKind::Postgres,
297                extra_float_digits: 1,
298                bytea_escape: false,
299                mysql: false,
300            },
301            tz_offset_fn: None,
302            tz_localize_fn: None,
303            tz_abbrev_fn: None,
304            salt_fn: None,
305            backend_pid_fn: None,
306            wal_lsn_fn: None,
307            backend_signal_fn: None,
308            clock: None,
309            xact: None,
310            assigned_xid: core::cell::Cell::new(None),
311        }
312    }
313
314    /// v7.39 (GUC knife 3) — attach the session render style.
315    #[must_use]
316    pub const fn with_render_style(mut self, style: crate::eval::format::RenderStyle) -> Self {
317        self.render_style = style;
318        self
319    }
320
321    /// v7.39 (round 318, V51) — attach the host connection-control hook.
322    #[must_use]
323    pub const fn with_backend_signal_fn(mut self, f: Option<crate::BackendSignalFn>) -> Self {
324        self.backend_signal_fn = f;
325        self
326    }
327
328    /// v7.39 (round 476) — attach the WAL byte-position provider.
329    #[must_use]
330    pub const fn with_wal_lsn_fn(mut self, f: Option<crate::WalLsnFn>) -> Self {
331        self.wal_lsn_fn = f;
332        self
333    }
334
335    /// v7.39 (read01 pgstatfuncs.c) — attach the calling-connection id.
336    #[must_use]
337    pub const fn with_backend_pid_fn(mut self, f: Option<crate::BackendPidFn>) -> Self {
338        self.backend_pid_fn = f;
339        self
340    }
341
342    /// v7.39 (tz epic) — attach the host timezone lookups.
343    #[must_use]
344    pub const fn with_tz_fns(
345        mut self,
346        offset: Option<crate::TzOffsetFn>,
347        localize: Option<crate::TzLocalizeFn>,
348        abbrev: Option<crate::TzAbbrevFn>,
349    ) -> Self {
350        self.tz_offset_fn = offset;
351        self.tz_localize_fn = localize;
352        self.tz_abbrev_fn = abbrev;
353        self
354    }
355
356    /// v7.39 (tz epic) — offset (µs east) of an arbitrary zone spec at
357    /// a UTC instant: fixed forms resolve statically, named zones
358    /// through the host tzdb. None = unknown zone.
359    #[must_use]
360    pub fn zone_offset_at(&self, zone: &str, utc_micros: i64) -> Option<i64> {
361        if let Some(off) = datetime::resolve_zone_offset(zone) {
362            return Some(off);
363        }
364        self.tz_offset_fn.and_then(|f| f(zone, utc_micros))
365    }
366
367    /// v7.39 (tz epic) — the SESSION zone's offset at a UTC instant
368    /// (per-value: DST zones vary within one statement).
369    #[must_use]
370    pub fn session_tz_offset_at(&self, utc_micros: i64) -> i64 {
371        let Some(zone) = self.session_gucs.and_then(|g| g.get("timezone")) else {
372            return 0;
373        };
374        self.zone_offset_at(zone, utc_micros).unwrap_or(0)
375    }
376
377    /// v7.39 (tz epic) — the session zone's designation at an instant
378    /// (named zones only; None lets renderers spell UTC / +HH).
379    #[must_use]
380    pub fn session_tz_abbrev_at(&self, utc_micros: i64) -> Option<alloc::string::String> {
381        let zone = self.session_gucs.and_then(|g| g.get("timezone"))?;
382        if datetime::resolve_zone_offset(zone).is_some()
383            || zone.eq_ignore_ascii_case("utc")
384            || zone.eq_ignore_ascii_case("gmt")
385        {
386            return None;
387        }
388        self.tz_abbrev_fn.and_then(|f| f(zone, utc_micros))
389    }
390
391    /// v7.39 (tz epic) — local wall micros in `zone` -> UTC micros
392    /// (PG's DST disambiguation for named zones).
393    #[must_use]
394    pub fn zone_local_to_utc(&self, zone: &str, local_micros: i64) -> Option<i64> {
395        zone_local_to_utc_with(zone, local_micros, self.tz_localize_fn)
396    }
397
398    /// v7.38 (read01 P5.24) — attach the host CSPRNG so cryptographic
399    /// builtins don't fall back to the predictable PRNG.
400    #[must_use]
401    pub const fn with_salt_fn(mut self, f: Option<crate::SaltFn>) -> Self {
402        self.salt_fn = f;
403        self
404    }
405
406    /// v7.38 (read01 P6.08) — attach the host wall clock so `uuidv7` gets a
407    /// real time-ordered prefix instead of the deterministic anchor.
408    #[must_use]
409    pub const fn with_clock(mut self, f: Option<crate::ClockFn>) -> Self {
410        self.clock = f;
411        self
412    }
413
414    /// v7.38 (read01 U15) — attach a per-scan `TABLESAMPLE REPEATABLE`
415    /// sampler cell. The cell (seeded lazily on first `__tsm_fract` draw)
416    /// must outlive the context and be created fresh per scan so a rescan
417    /// re-seeds and reproduces the same sample.
418    #[must_use]
419    pub const fn with_sample_rng(mut self, cell: &'a core::cell::Cell<Option<u64>>) -> Self {
420        self.sample_rng = Some(cell);
421        self
422    }
423
424    /// Attach the session's GUC map so `current_setting` can resolve
425    /// custom (namespaced) settings written with `SET` / `set_config`.
426    #[must_use]
427    /// v7.39 (read01 round 63) — thread the engine (see `engine`).
428    pub const fn with_engine(mut self, engine: &'a crate::Engine) -> Self {
429        self.mysql_dialect = engine.backslash_escapes;
430        // v7.39 (round 368, M20 P3) — the dialect also decides how a binary
431        // string renders in a string context (latin-1 bytes vs PG `\x…`).
432        self.render_style.mysql = engine.backslash_escapes;
433        self.engine = Some(engine);
434        self
435    }
436
437    /// v7.39 (read01 round 58) — thread the role store (see `users`).
438    pub const fn with_users(mut self, users: &'a crate::users::UserStore) -> Self {
439        self.users = Some(users);
440        self
441    }
442
443    /// v7.39 (round 524) — attach a whole session bag at once. Every
444    /// write path needs the same four, and taking them one at a time is
445    /// how three of them ended up with none.
446    #[must_use]
447    pub(crate) fn with_session<'b: 'a>(mut self, s: &'b DmlSession) -> Self {
448        self.session_gucs = Some(&s.gucs);
449        self.users = Some(&s.users);
450        self.render_style = s.render_style;
451        self.tz_offset_fn = s.tz_offset_fn;
452        self.tz_localize_fn = s.tz_localize_fn;
453        self.tz_abbrev_fn = s.tz_abbrev_fn;
454        self
455    }
456
457    pub const fn with_session_gucs(
458        mut self,
459        gucs: &'a alloc::collections::BTreeMap<String, String>,
460    ) -> Self {
461        self.session_gucs = Some(gucs);
462        self
463    }
464
465    /// v7.37.16 (16.12) — attach a read-only catalog reference
466    /// so builtins like `pg_partition_root` can walk partition
467    /// roles. Defaults to None (NULL semantics).
468    #[must_use]
469    pub const fn with_catalog(mut self, catalog: &'a spg_storage::Catalog) -> Self {
470        self.catalog = Some(catalog);
471        self
472    }
473
474    /// v7.38 (T-tstz Phase 2) — the micro-offset of the session `TimeZone` GUC
475    /// (`SET TimeZone = '+09'` → +9h). A fixed offset / abbreviation resolves;
476    /// UTC and an unset GUC give 0; a named IANA zone (no tzdata) also gives 0
477    /// so a timestamptz still renders — as `+00` — rather than erroring on
478    /// every display. Timestamptz rendering / cast is the only consumer.
479    #[must_use]
480    pub fn session_tz_offset(&self) -> i64 {
481        self.session_gucs
482            .and_then(|g| g.get("timezone"))
483            .and_then(|z| datetime::resolve_zone_offset(z))
484            .unwrap_or(0)
485    }
486
487    /// v7.38 (T24) — attach the transaction-version view the `txid_*` builtins
488    /// read. Defaults to None, where they fall back to the process-wide cursor.
489    #[must_use]
490    pub const fn with_xact(mut self, xact: XactView<'a>) -> Self {
491        self.xact = Some(xact);
492        self
493    }
494
495    /// v7.17.0 — attach a sequence resolver. The engine wraps a
496    /// `&mut Catalog` in a closure that performs the requested
497    /// SequenceOp.
498    #[must_use]
499    pub const fn with_sequence_resolver(mut self, resolver: &'a SequenceResolver<'a>) -> Self {
500        self.sequence_resolver = Some(resolver);
501        self
502    }
503
504    /// v6.1.1 — attach a parameter buffer for `$N` placeholder
505    /// resolution. The slice must outlive the context; callers
506    /// construct it from the prepared statement's Bind values.
507    #[must_use]
508    pub const fn with_params(mut self, params: &'a [Value<'static>]) -> Self {
509        self.params = params;
510        self
511    }
512
513    /// v7.12.1 — attach the session's
514    /// `default_text_search_config`. Used by the FTS function
515    /// dispatcher when no explicit config arg is given.
516    #[must_use]
517    pub const fn with_default_text_search_config(mut self, cfg: Option<&'a str>) -> Self {
518        self.default_text_search_config = cfg;
519        self
520    }
521}
522
523/// v7.39 (round 523) — read a timestamp literal, reporting whether it
524/// carried an offset. Re-exported for the INSERT path, which decides
525/// there whether a value already names an instant.
526pub(crate) fn parse_timestamp_literal_tz_ordered_pub(
527    s: &str,
528    order: DateOrder,
529) -> Option<(i64, bool)> {
530    format::parse_timestamp_literal_tz_ordered(s, order)
531}
532
533/// v7.39 (round 523) — a FIXED zone's offset, when the name is one
534/// (`+09`, `UTC-5`). Named zones go through the host's tzdb instead.
535#[must_use]
536pub(crate) fn resolve_zone_offset_pub(zone: &str) -> Option<i64> {
537    datetime::resolve_zone_offset(zone)
538}
539
540/// v7.39 (round 523) — a wall-clock reading in `zone` as a UTC instant.
541///
542/// A free function because the INSERT path needs it too, and that path
543/// carries no `EvalContext`: it evaluates VALUES through a context-free
544/// literal walker. `EvalContext::zone_local_to_utc` delegates here so the
545/// two cannot drift.
546#[must_use]
547pub(crate) fn zone_local_to_utc_with(
548    zone: &str,
549    local_micros: i64,
550    localize: Option<crate::TzLocalizeFn>,
551) -> Option<i64> {
552    if let Some(off) = datetime::resolve_zone_offset(zone) {
553        return Some(local_micros - off);
554    }
555    localize.and_then(|f| f(zone, local_micros))
556}
557
558/// v7.39 (round 523) — the session zone an assignment to a timestamptz
559/// column is read in, or `None` when the session is on UTC and no shift
560/// applies.
561#[derive(Debug, Clone)]
562pub(crate) struct SessionCoercion {
563    /// The session zone, when it is not UTC. `None` leaves an instant
564    /// where it was.
565    pub zone: Option<alloc::string::String>,
566    pub localize: Option<crate::TzLocalizeFn>,
567    /// The session's date order. A written date is ambiguous
568    /// (`01/02/2020`), and this is what resolves it.
569    pub order: DateOrder,
570}
571
572impl SessionCoercion {
573    /// The UTC instant a naive wall-clock reading names in the session
574    /// zone, or `None` when the session is on UTC.
575    #[must_use]
576    pub(crate) fn wall_to_utc(&self, wall: i64) -> Option<i64> {
577        let zone = self.zone.as_ref()?;
578        zone_local_to_utc_with(zone, wall, self.localize)
579    }
580
581    /// v7.39 (round 524) — the session facts an ASSIGNMENT is read
582    /// under, from an evaluation context. `None` when both are the
583    /// defaults and nothing needs re-reading.
584    #[must_use]
585    pub(crate) fn from_ctx(ctx: &EvalContext<'_>) -> Option<Self> {
586        let zone = ctx
587            .session_gucs
588            .and_then(|g| g.get("timezone"))
589            .filter(|z| !z.eq_ignore_ascii_case("utc") && !z.eq_ignore_ascii_case("gmt"))
590            .cloned();
591        let order = ctx.render_style.date_order;
592        if zone.is_none() && order == DateOrder::Mdy {
593            return None;
594        }
595        Some(Self {
596            zone,
597            localize: ctx.tz_localize_fn,
598            order,
599        })
600    }
601}
602
603/// v7.39 (round 524) — the session facts a DML evaluation context needs,
604/// cloned so the row loop can still borrow the engine mutably.
605///
606/// Every write path built a BARE `EvalContext`, so an expression in an
607/// UPDATE's SET or a DELETE's WHERE was evaluated by an engine that knew
608/// nothing about the connection. One value, built once per statement,
609/// and a grep for `dml_session` finds every path that has it.
610pub(crate) struct DmlSession {
611    pub gucs: alloc::collections::BTreeMap<String, String>,
612    pub users: crate::users::UserStore,
613    pub render_style: RenderStyle,
614    pub tz_offset_fn: Option<crate::TzOffsetFn>,
615    pub tz_localize_fn: Option<crate::TzLocalizeFn>,
616    pub tz_abbrev_fn: Option<crate::TzAbbrevFn>,
617}
618
619/// v7.39 (round 524) — read a TEXT value bound for a temporal column
620/// under the session's date order.
621///
622/// `01/02/2020` is February 1st in a DMY session and January 2nd in an
623/// MDY one, and the write path was reading every one of them as MDY: a
624/// `SELECT '01/02/2020'::date` answered PG's value while the same
625/// literal INSERTed stored the day and month swapped. Nothing errors,
626/// and once stored the two readings are indistinguishable.
627#[must_use]
628pub(crate) fn session_read_temporal_text(
629    v: Value<'static>,
630    target: spg_storage::DataType,
631    coercion: Option<&SessionCoercion>,
632) -> Value<'static> {
633    use spg_storage::DataType as D;
634    let Some(c) = coercion else { return v };
635    if c.order == DateOrder::Mdy {
636        return v;
637    }
638    let Value::Text(s) = &v else { return v };
639    match target {
640        D::Date => format::parse_date_literal_ordered(s, c.order).map_or(v, Value::Date),
641        D::Timestamp | D::Timestamptz => format::parse_timestamp_literal_tz_ordered(s, c.order)
642            .map_or(v, |(t, _)| Value::Timestamp(t)),
643        _ => v,
644    }
645}
646
647#[derive(Debug, Clone, PartialEq)]
648pub enum EvalError {
649    ColumnNotFound {
650        name: String,
651    },
652    UnknownQualifier {
653        qualifier: String,
654    },
655    DivisionByZero,
656    TypeMismatch {
657        detail: String,
658    },
659    /// v6.1.1 — `$N` reference past the number of bound parameters.
660    /// Either the client sent too few in Bind, or the SQL has a
661    /// placeholder the prepared statement didn't account for.
662    PlaceholderOutOfRange {
663        n: u16,
664        bound: u16,
665    },
666    /// v7.38 (read01 P3.25) — the expression tree recursed deep enough to
667    /// threaten a native stack overflow; we bail out with an error the way
668    /// PG's `check_stack_depth()` does instead of aborting the process.
669    StackDepthExceeded,
670}
671
672impl core::fmt::Display for EvalError {
673    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
674        match self {
675            // v7.39 (read01 round 81) — PG's wording (and SQLSTATE trigger):
676            // `column "x" does not exist`, 42703. The old "column not found: x"
677            // matched none of the wire layer's `does not exist` patterns, so a
678            // missing column reached the client as the generic error class.
679            Self::ColumnNotFound { name } => write!(f, "column \"{name}\" does not exist"),
680            // v7.39 (round 241) — PG's wording (and 42P01 trigger): a
681            // qualifier that names no table in scope is "missing
682            // FROM-clause entry for table \"x\"". The old "unknown table
683            // qualifier" matched nothing a driver branches on.
684            Self::UnknownQualifier { qualifier } => {
685                write!(f, "missing FROM-clause entry for table \"{qualifier}\"")
686            }
687            Self::DivisionByZero => f.write_str("division by zero"),
688            Self::TypeMismatch { detail } => write!(f, "type mismatch: {detail}"),
689            Self::PlaceholderOutOfRange { n, bound } => write!(
690                f,
691                "parameter ${n} referenced but only {bound} bound by client"
692            ),
693            Self::StackDepthExceeded => {
694                f.write_str("stack depth limit exceeded (expression nested too deeply)")
695            }
696        }
697    }
698}
699
700/// v7.38 (read01 P3.25) — native-stack budget below the outermost
701/// `eval_expr` frame. Native stacks are typically 2–8 MB; 768 KiB leaves
702/// generous headroom while still permitting PG-class nesting depth (in a
703/// release build ~hundreds-to-thousands of frames fit under this).
704const MAX_EVAL_STACK_BYTES: usize = 768 * 1024;
705
706/// Address of a local in the current frame — a portable stand-in for the
707/// stack pointer (stacks grow downward on all supported targets).
708#[inline(never)]
709fn eval_stack_ptr() -> usize {
710    let probe = 0u8;
711    core::ptr::addr_of!(probe) as usize
712}
713
714/// v7.38 (read01 P6.40) — enforce a user DOMAIN's NOT NULL + CHECK constraints
715/// on a value being cast to it (`x::domain`). NULL fails a NOT NULL domain;
716/// otherwise every CHECK (which references the pseudo-column `VALUE`) must not
717/// evaluate to false. Returns the value unchanged when all constraints pass.
718fn apply_domain_constraints<'a>(
719    v: Value<'a>,
720    dom: &spg_storage::DomainDef,
721    name: &str,
722    cat: &spg_storage::Catalog,
723) -> Result<Value<'a>, EvalError> {
724    if matches!(v, Value::Null) {
725        // A NOT NULL anywhere in the chain rejects a NULL.
726        let mut cur = Some(dom);
727        while let Some(d) = cur {
728            if !d.nullable {
729                return Err(EvalError::TypeMismatch {
730                    detail: alloc::format!("domain {name} does not allow null values"),
731                });
732            }
733            cur = d
734                .base_domain
735                .as_ref()
736                .and_then(|p| cat.domain_types().get(p.as_str()));
737        }
738        return Ok(v);
739    }
740    // v7.39 (round 259) — walk the domain chain BASE-FIRST (probed: a
741    // value violating both a parent's and the child's constraint reports
742    // the PARENT's). The message names the domain being cast TO, but the
743    // constraint that actually failed — `value for domain pchild violates
744    // check constraint "pbase_check"`.
745    let mut chain: alloc::vec::Vec<&spg_storage::DomainDef> = alloc::vec![dom];
746    let mut cur = dom;
747    while let Some(parent) = cur
748        .base_domain
749        .as_ref()
750        .and_then(|p| cat.domain_types().get(p.as_str()))
751    {
752        // A cycle cannot be created through CREATE DOMAIN (the parent must
753        // already exist), but stop defensively rather than loop forever.
754        if chain.iter().any(|d| core::ptr::eq(*d, parent)) {
755            break;
756        }
757        chain.push(parent);
758        cur = parent;
759    }
760    chain.reverse();
761    for owner in chain {
762        apply_domain_checks_of(&v, owner, name)?;
763    }
764    Ok(v)
765}
766
767/// v7.39 (round 259) — run ONE domain's own CHECK list against `v`. The
768/// error names `target` (the domain the value is being cast to) and
769/// `owner` (whose constraint failed); for a single-level domain they are
770/// the same, which is the pre-259 wording.
771fn apply_domain_checks_of(
772    v: &Value<'_>,
773    dom: &spg_storage::DomainDef,
774    target: &str,
775) -> Result<(), EvalError> {
776    let name = target;
777    for chk in &dom.checks {
778        let src = &chk.expr;
779        // v7.39 (round 260) — report the constraint that failed by NAME.
780        let owner = chk.name.as_str();
781        let expr = spg_sql::parser::parse_expression(src).map_err(|e| EvalError::TypeMismatch {
782            detail: alloc::format!("domain {name} CHECK ({src:?}) failed to re-parse: {e:?}"),
783        })?;
784        let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
785            "value",
786            dom.base_type,
787            dom.nullable,
788        )];
789        let synth_ctx = EvalContext::new(&synth_cols, None);
790        // Owned copy so the temporary row doesn't borrow `v`'s lifetime.
791        let synth_row = spg_storage::Row {
792            values: alloc::vec![v.clone().into_owned()],
793        };
794        let r = eval_expr(&expr, &synth_row, &synth_ctx)?;
795        if matches!(r, Value::Bool(false)) {
796            return Err(EvalError::TypeMismatch {
797                detail: alloc::format!(
798                    "value for domain {name} violates check constraint \"{owner}\""
799                ),
800            });
801        }
802    }
803    Ok(())
804}
805
806/// v7.38 (read01 P6.67) — validate a value cast to a user ENUM: a text label
807/// must be one of the enum's members (else error, as PG does); a NULL is a
808/// valid typed null. The stored representation stays the text label.
809/// v7.39 (read01 rowtypes.c) — cast into a user composite type: parse the
810/// `(v1,"v 2",)` record text (double-quote wrapping with doubled quotes,
811/// empty field = NULL) and coerce each field to the declared type; a ROW
812/// value re-labels positionally.
813/// v7.39 (round 350/351, M7 + M11) — how MySQL reads a TEXT operand of
814/// an arithmetic or comparison operator. The identity in the PG dialect,
815/// and out-of-line so it costs the recursive `eval_expr` frame nothing.
816///
817/// Measured on MariaDB 11: `'2024-01-15' + INTERVAL 1 DAY` shifts the
818/// date; `'1abc'+0` is 1, `'abc'+0` is 0, `'2024-01-15'+0` is 2024; two
819/// strings compare as STRINGS (`'10' > '9'` is 0) while a mixed pair
820/// compares numerically (`'10' > 9` is 1).
821#[inline(never)]
822pub(crate) fn mysql_operand_reading_pair(
823    op: BinOp,
824    l: Value<'static>,
825    r: Value<'static>,
826) -> (Value<'static>, Value<'static>) {
827    if !mysql_coerces(op) {
828        return (l, r);
829    }
830    match (&l, &r) {
831        (Value::Text(t), Value::Interval { .. }) => (text_as_temporal(t).unwrap_or(l.clone()), r),
832        (Value::Interval { .. }, Value::Text(t)) => {
833            let rr = text_as_temporal(t).unwrap_or(r.clone());
834            (l, rr)
835        }
836        // v7.39 (round 353, M10) — a boolean IS an integer in MySQL, so
837        // `!1 + 1` is 1 (measured). It was `operator does not exist:
838        // boolean + integer`.
839        (Value::Bool(b), other)
840            if mysql_arith(op) && other.data_type().is_some_and(is_numeric_type) =>
841        {
842            (Value::BigInt(i64::from(*b)), r)
843        }
844        (other, Value::Bool(b))
845            if mysql_arith(op) && other.data_type().is_some_and(is_numeric_type) =>
846        {
847            let rr = Value::BigInt(i64::from(*b));
848            (l, rr)
849        }
850        (Value::Text(t), other) if other.data_type().is_some_and(is_numeric_type) => {
851            (mysql_number_of(t), r)
852        }
853        (other, Value::Text(t)) if other.data_type().is_some_and(is_numeric_type) => {
854            let rr = mysql_number_of(t);
855            (l, rr)
856        }
857        // v7.39 (round 367, M20 P2) — a binary string beside a number
858        // reads as its big-endian integer value (`0x10 + 0` = 16,
859        // `0x10 = 16` is true). Beside a Text operand it stays bytes so
860        // the byte-wise string compare (`0x61 = 'a'`) still fires.
861        (Value::Bytes(b), other) if other.data_type().is_some_and(is_numeric_type) => {
862            (mysql_bytes_as_number(b), r)
863        }
864        (other, Value::Bytes(b)) if other.data_type().is_some_and(is_numeric_type) => {
865            let rr = mysql_bytes_as_number(b);
866            (l, rr)
867        }
868        // Arithmetic between two strings is numeric; comparison is not.
869        (Value::Text(a), Value::Text(b)) if mysql_arith(op) => {
870            (mysql_number_of(a), mysql_number_of(b))
871        }
872        _ => (l, r),
873    }
874}
875
876/// Is this a mixed string/number pair, which MySQL compares numerically?
877fn mysql_mixed_pair(l: &Value<'_>, r: &Value<'_>) -> bool {
878    matches!((l, r), (Value::Text(_), o) | (o, Value::Text(_))
879        if o.data_type().is_some_and(is_numeric_type))
880}
881
882const fn mysql_arith(op: BinOp) -> bool {
883    matches!(
884        op,
885        BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod
886    )
887}
888
889/// Does this comparison need the owned path — because a value's type
890/// wants it, because a CI collation folds it, or (MySQL) because a mixed
891/// string/number pair compares NUMERICALLY there while two strings
892/// compare as strings.
893#[inline(never)]
894fn needs_owned_compare(
895    lc: &Value<'_>,
896    rc: &Value<'_>,
897    lhs: &Expr,
898    rhs: &Expr,
899    ctx: &EvalContext<'_>,
900) -> bool {
901    is_owned_compare_value(lc)
902        || is_owned_compare_value(rc)
903        || compare_is_case_insensitive(lhs, rhs, ctx)
904        || (ctx.mysql_dialect && mysql_mixed_pair(lc, rc))
905}
906
907/// Which operators take MySQL's string→number reading.
908const fn mysql_coerces(op: BinOp) -> bool {
909    matches!(
910        op,
911        BinOp::Add
912            | BinOp::Sub
913            | BinOp::Mul
914            | BinOp::Div
915            | BinOp::Mod
916            | BinOp::Eq
917            | BinOp::NotEq
918            | BinOp::Lt
919            | BinOp::LtEq
920            | BinOp::Gt
921            | BinOp::GtEq
922    )
923}
924
925/// Does this type take part in MySQL's numeric coercion?
926fn is_numeric_type(t: spg_storage::DataType) -> bool {
927    use spg_storage::DataType as D;
928    matches!(
929        t,
930        D::SmallInt | D::Int | D::BigInt | D::Float | D::Real | D::Numeric { .. }
931    )
932}
933
934/// v7.39 (round 364, M4 P2) — a value as it participates in a MySQL
935/// session's default-collation comparison: text folds (accent- and
936/// case-insensitive), everything else is itself. Used by IN and LIKE,
937/// whose comparisons do not pass through `collation_fold_for_compare`.
938fn mysql_collation_key(v: Value<'static>, mysql: bool) -> Value<'static> {
939    match v {
940        Value::Text(s) if mysql => Value::text(spg_storage::mysql_compare_fold(&s)),
941        other => other,
942    }
943}
944
945/// A string as MySQL reads it in numeric position: an exact integer when
946/// the leading number is one, otherwise a double.
947#[inline(never)]
948fn mysql_number_of(s: &str) -> Value<'static> {
949    let n = mysql_leading_number(s);
950    if n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 {
951        #[allow(clippy::cast_possible_truncation)]
952        Value::BigInt(n as i64)
953    } else {
954        Value::Float(n)
955    }
956}
957
958/// v7.39 (round 367, M20 P2) — a MySQL binary string (a `0x…` / `X'…'` /
959/// `b'…'` literal, backed by `Value::Bytes`) reads as its bytes'
960/// BIG-ENDIAN unsigned integer in a numeric context: `0x4142 + 0` is
961/// 16706, `0x10 = 16` is true (measured on MariaDB 11). Only the low 16
962/// bytes participate — a hex literal used in arithmetic is at most an
963/// 8-byte BIGINT in practice — and a value past `i64::MAX` becomes a
964/// NUMERIC so nothing wraps negative.
965fn mysql_bytes_as_number(b: &[u8]) -> Value<'static> {
966    let start = b.len().saturating_sub(16);
967    let acc = b[start..]
968        .iter()
969        .fold(0u128, |a, &x| (a << 8) | u128::from(x));
970    if acc <= i64::MAX as u128 {
971        #[allow(clippy::cast_possible_truncation)]
972        Value::BigInt(acc as i64)
973    } else {
974        crate::conversions::big_literal_to_value(&alloc::format!("{acc}"))
975    }
976}
977
978/// MySQL's `/`: a real division, and NULL on a zero divisor. `None`
979/// when this pairing is not the integer/integer case PG and MySQL
980/// disagree about.
981#[inline(never)]
982pub(crate) fn mysql_true_division(
983    op: BinOp,
984    l: &Value<'_>,
985    r: &Value<'_>,
986    text_operand: bool,
987) -> Option<Value<'static>> {
988    // v7.39 (round 372) — MySQL's `x % 0` / `x MOD 0` is NULL, not the PG
989    // "division by zero" error (measured on MariaDB 11: `10%0`, `10 MOD
990    // 0`, `10.5%0` are all NULL, matching `1/0`). A non-zero divisor takes
991    // the normal modulo path.
992    if matches!(op, BinOp::Mod) {
993        return if value_is_zero(r) {
994            Some(Value::Null)
995        } else {
996            None
997        };
998    }
999    if !matches!(op, BinOp::Div) {
1000        return None;
1001    }
1002    // v7.39 (round 393) — MariaDB `/` on exact (int / decimal) operands is a
1003    // DECIMAL whose scale is the LEFT operand's scale + 4 (`7/2` is 3.5000,
1004    // `10.0/3` is 3.33333, `7.00/2` is 3.500000), NOT a float. A float /
1005    // double operand — or a STRING one, `'10'/'4'` is 2.5 (double) — makes
1006    // the result a float; a zero divisor is NULL.
1007    if text_operand
1008        || matches!(l, Value::Float(_) | Value::Real(_))
1009        || matches!(r, Value::Float(_) | Value::Real(_))
1010    {
1011        let f = |v: &Value<'_>| -> Option<f64> {
1012            match v {
1013                Value::Float(x) => Some(*x),
1014                Value::Real(x) => Some(f64::from(*x)),
1015                Value::SmallInt(n) => Some(f64::from(*n)),
1016                Value::Int(n) => Some(f64::from(*n)),
1017                #[allow(clippy::cast_precision_loss)]
1018                Value::BigInt(n) => Some(*n as f64),
1019                _ => None,
1020            }
1021        };
1022        let (a, b) = (f(l)?, f(r)?);
1023        return Some(if b == 0.0 {
1024            Value::Null
1025        } else {
1026            Value::Float(a / b)
1027        });
1028    }
1029    let (ls, lsc) = exact_decimal_parts(l)?;
1030    let (rs, rsc) = exact_decimal_parts(r)?;
1031    if rs == 0 {
1032        return Some(Value::Null);
1033    }
1034    let result_scale = u32::from(lsc) + 4;
1035    // result_scaled = round( ls * 10^(rsc + 4) / rs ), half away from zero.
1036    let pow = 10i128.checked_pow(u32::from(rsc) + 4)?;
1037    let num = ls.checked_mul(pow)?;
1038    let q = num / rs;
1039    let rem = num % rs;
1040    let bump = if rem.unsigned_abs() * 2 >= rs.unsigned_abs() {
1041        if (num < 0) == (rs < 0) { 1 } else { -1 }
1042    } else {
1043        0
1044    };
1045    Some(Value::numeric(q + bump, u16::try_from(result_scale).ok()?))
1046}
1047
1048/// The `(scaled, scale)` of an exact integer / NUMERIC value: an integer
1049/// has scale 0. None for a float / non-numeric (they take the float path).
1050fn exact_decimal_parts(v: &Value<'_>) -> Option<(i128, u16)> {
1051    match v {
1052        Value::SmallInt(n) => Some((i128::from(*n), 0)),
1053        Value::Int(n) => Some((i128::from(*n), 0)),
1054        Value::BigInt(n) => Some((i128::from(*n), 0)),
1055        Value::Numeric {
1056            scaled,
1057            scale,
1058            kind: spg_storage::NumericKind::Finite,
1059        } => Some((*scaled, *scale)),
1060        _ => None,
1061    }
1062}
1063
1064/// v7.39 (round 383) — the UNSIGNED 64-bit value a MySQL bitwise operand
1065/// reads as. MySQL's `& | ^ ~ << >>` all work on `BIGINT UNSIGNED`, so an
1066/// operand is its 64-bit two's-complement pattern (a negative integer:
1067/// `-5` is `0xFFFF…FB`), rounded to the nearest integer (a float / numeric:
1068/// `2.9` is 3), its big-endian value (a `0x…` binary string), or its
1069/// leading number (a string). Anything else (an inet, a range, a
1070/// bit-string) returns None so the operator keeps its own meaning.
1071#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1072fn mysql_bit_u64(v: &Value<'_>) -> Option<u64> {
1073    match v {
1074        Value::SmallInt(n) => Some(i64::from(*n) as u64),
1075        Value::Int(n) => Some(i64::from(*n) as u64),
1076        Value::BigInt(n) => Some(*n as u64),
1077        Value::Bool(b) => Some(u64::from(*b)),
1078        Value::Float(x) => Some(x.round() as i64 as u64),
1079        Value::Real(x) => Some(f64::from(*x).round() as i64 as u64),
1080        Value::Numeric {
1081            scaled,
1082            scale,
1083            kind: spg_storage::NumericKind::Finite,
1084        } => {
1085            // Round half away from zero to an integer, then take the low
1086            // 64 bits (two's complement) — `~2.9` is `~3`.
1087            if *scale > 38 {
1088                return None;
1089            }
1090            let div = 10i128.pow(u32::from(*scale));
1091            let q = scaled / div;
1092            let rem = scaled % div;
1093            let rounded = if rem.unsigned_abs() * 2 >= div.unsigned_abs() {
1094                q + scaled.signum()
1095            } else {
1096                q
1097            };
1098            Some(rounded as u64)
1099        }
1100        Value::Bytes(b) => mysql_bit_u64(&mysql_bytes_as_number(b)),
1101        Value::Text(s) => mysql_bit_u64(&mysql_number_of(s)),
1102        _ => None,
1103    }
1104}
1105
1106/// A MySQL bitwise result — a `BIGINT UNSIGNED`. It stays a signed
1107/// `BigInt` while it fits (so an integer-context consumer — MAKE_SET
1108/// bits, ELT / SUBSTRING / REPEAT counts — still takes it); a value past
1109/// `i64::MAX` (a set bit 63, e.g. `~5`) has no signed integer type, so it
1110/// becomes a scale-0 NUMERIC, which holds the whole `0..=2^64-1` range and
1111/// renders as the plain integer MySQL prints.
1112fn u64_as_value(n: u64) -> Value<'static> {
1113    match i64::try_from(n) {
1114        Ok(v) => Value::BigInt(v),
1115        Err(_) => Value::numeric(i128::from(n), 0),
1116    }
1117}
1118
1119/// v7.39 (round 383) — the MySQL bitwise operators on UNSIGNED 64-bit
1120/// integers. Returns None when either operand is not number-like (so an
1121/// `inet << int` / `bit(n) & bit(n)` / geometric `#` keeps its own path)
1122/// or the operator is not bitwise. A shift of 64 or more is 0 (MySQL does
1123/// not mask the shift count).
1124pub(crate) fn mysql_bitwise(op: BinOp, l: &Value<'_>, r: &Value<'_>) -> Option<Value<'static>> {
1125    let out = match op {
1126        BinOp::BitAnd => mysql_bit_u64(l)? & mysql_bit_u64(r)?,
1127        BinOp::BitOr => mysql_bit_u64(l)? | mysql_bit_u64(r)?,
1128        BinOp::BitXor => mysql_bit_u64(l)? ^ mysql_bit_u64(r)?,
1129        // `<<` / `>>` share the inet-containment BinOps; a numeric pair is a
1130        // shift, anything else stays inet / range / bit-string.
1131        BinOp::InetContainedBy => {
1132            let (a, n) = (mysql_bit_u64(l)?, mysql_bit_u64(r)?);
1133            if n >= 64 { 0 } else { a << n }
1134        }
1135        BinOp::InetContains => {
1136            let (a, n) = (mysql_bit_u64(l)?, mysql_bit_u64(r)?);
1137            if n >= 64 { 0 } else { a >> n }
1138        }
1139        _ => return None,
1140    };
1141    Some(u64_as_value(out))
1142}
1143
1144/// v7.39 (round 383) — MySQL unary `~x`: the UNSIGNED 64-bit complement
1145/// (`~5` is 18446744073709551610). None for a non-number operand (a
1146/// bit-string / inet / macaddr keeps PG's typed complement).
1147pub(crate) fn mysql_bit_not(v: &Value<'_>) -> Option<Value<'static>> {
1148    Some(u64_as_value(!mysql_bit_u64(v)?))
1149}
1150
1151/// v7.39 (round 390, type-fidelity epic P5) — the inline `SET('a','b',…)`
1152/// variant list an expression's column is declared with, or None. Mirrors
1153/// `expr_enum_type_name` — a bare `Expr::Column` looked up by name.
1154pub(crate) fn expr_set_variants<'e>(
1155    e: &'e Expr,
1156    columns: &'e [ColumnSchema],
1157) -> Option<&'e [String]> {
1158    match e {
1159        Expr::Column(c) => columns
1160            .iter()
1161            .find(|col| col.name == c.name)
1162            .and_then(|col| col.inline_set_variants.as_deref()),
1163        _ => None,
1164    }
1165}
1166
1167/// v7.39 (round 402) — the inline `ENUM('a','b',…)` variant list an
1168/// expression's column is declared with, or None. Like `expr_set_variants`.
1169pub(crate) fn expr_inline_enum_variants<'e>(
1170    e: &'e Expr,
1171    columns: &'e [ColumnSchema],
1172) -> Option<&'e [String]> {
1173    match e {
1174        Expr::Column(c) => columns
1175            .iter()
1176            .find(|col| col.name == c.name)
1177            .and_then(|col| col.inline_enum_variants.as_deref()),
1178        _ => None,
1179    }
1180}
1181
1182/// The 1-based ordinal a stored inline-ENUM text carries in a numeric
1183/// context (`e + 0` is 1 for the first member); the empty string / an
1184/// unknown member is 0 (MySQL's implicit `''` enum error value).
1185pub(crate) fn enum_text_to_ordinal(text: &str, variants: &[String]) -> i64 {
1186    variants
1187        .iter()
1188        .position(|v| v == text)
1189        .map_or(0, |p| p as i64 + 1)
1190}
1191
1192/// The bitmask a stored SET text carries in a numeric context: each
1193/// comma-separated member contributes `1 << its position` in the declared
1194/// variant list (`'a,c'` over `('a','b','c','d')` is 1 | 4 = 5). An empty
1195/// string is 0; an unknown member (should not occur — the write path
1196/// validates) contributes nothing.
1197pub(crate) fn set_text_to_bitmask(text: &str, variants: &[String]) -> i64 {
1198    if text.is_empty() {
1199        return 0;
1200    }
1201    let mut bits = 0i64;
1202    for member in text.split(',') {
1203        if let Some(pos) = variants.iter().position(|v| v == member) {
1204            bits |= 1i64 << pos;
1205        }
1206    }
1207    bits
1208}
1209
1210/// Is this an arithmetic / bitwise operator MySQL evaluates a SET column
1211/// numerically under? (`s + 0`, `s & flag`, …). The comparison operators
1212/// are NOT here — `s = 'a,c'` stays a text compare.
1213pub(crate) const fn is_mysql_numeric_binop(op: BinOp) -> bool {
1214    matches!(
1215        op,
1216        BinOp::Add
1217            | BinOp::Sub
1218            | BinOp::Mul
1219            | BinOp::Div
1220            | BinOp::Mod
1221            | BinOp::BitAnd
1222            | BinOp::BitOr
1223            | BinOp::BitXor
1224            | BinOp::InetContainedBy
1225            | BinOp::InetContains
1226    )
1227}
1228
1229/// v7.39 (round 372) — is `v` a numeric zero (any width / kind)? Used to
1230/// route `x % 0` / `MOD(x, 0)` to NULL under the MySQL dialect.
1231pub(crate) fn value_is_zero(v: &Value<'_>) -> bool {
1232    match v {
1233        Value::SmallInt(n) => *n == 0,
1234        Value::Int(n) => *n == 0,
1235        Value::BigInt(n) => *n == 0,
1236        Value::Float(x) => *x == 0.0,
1237        Value::Real(x) => *x == 0.0,
1238        Value::Numeric { scaled, .. } => *scaled == 0,
1239        _ => false,
1240    }
1241}
1242
1243/// `-'5'` in the MySQL dialect. Out-of-line for the same frame reason.
1244#[inline(never)]
1245fn mysql_negate_text(
1246    op: spg_sql::ast::UnOp,
1247    v: &Value<'static>,
1248) -> Option<Result<Value<'static>, EvalError>> {
1249    match v {
1250        Value::Text(t) => Some(apply_unary(op, mysql_number_of(t))),
1251        _ => None,
1252    }
1253}
1254
1255/// The MySQL reading of a unary operator, or None to let `apply_unary` (the
1256/// PG path) run. Kept out of `eval_expr`'s recursive frame — see the
1257/// round-383 frame cliff. Covers `NOT` on any truth value (round 346),
1258/// `-'str'` numeric negation (round 351), and the unsigned `~` complement
1259/// (round 383).
1260#[inline(never)]
1261fn mysql_unary_arm(
1262    op: spg_sql::ast::UnOp,
1263    v: &Value<'static>,
1264) -> Option<Result<Value<'static>, EvalError>> {
1265    use spg_sql::ast::UnOp;
1266    match op {
1267        // `NOT 5` is 0 — read any non-bool as a truth value; a bool / NULL
1268        // keeps the PG path (still refused there for non-bool).
1269        UnOp::Not if !matches!(v, Value::Bool(_) | Value::Null) => Some(mysql_not(v)),
1270        // `-'5'` is -5, `-'abc'` is 0.
1271        UnOp::Neg => mysql_negate_text(op, v),
1272        // `+ anything` is that thing: measured on MariaDB 11, `+'x'` is
1273        // 'x', `+TRUE` is 1, `+NULL` is NULL. No type check at all, unlike
1274        // PG, which refuses every non-numeric operand.
1275        UnOp::Plus => Some(Ok(v.clone())),
1276        // `~5` is the unsigned 64-bit complement; NULL stays NULL (PG path).
1277        UnOp::BitNot if !matches!(v, Value::Null) => mysql_bit_not(v).map(Ok),
1278        _ => None,
1279    }
1280}
1281
1282/// A date / timestamp string as its temporal value, or `None` when it is
1283/// not one (in which case the operand is left exactly as it was).
1284#[inline(never)]
1285fn text_as_temporal(t: &str) -> Option<Value<'static>> {
1286    parse_timestamp_literal(t)
1287        .map(Value::Timestamp)
1288        .or_else(|| parse_date_literal(t).map(Value::Date))
1289}
1290
1291/// v7.39 (round 620) — an unadorned string literal, which is what PG calls
1292/// `unknown`: a value whose type the context gets to choose. `''::TEXT` is
1293/// not one, and neither is a text column.
1294fn is_unknown_string_literal(e: &Expr) -> bool {
1295    matches!(e, Expr::Literal(spg_sql::ast::Literal::String(_)))
1296}
1297
1298/// v7.39 (round 620) — resolve such a literal to boolean, which is what a
1299/// boolean connective asks of it. An unparseable one is PG's input-syntax
1300/// error (22P02), not a type complaint: `'a' AND true` says
1301/// `invalid input syntax for type boolean: "a"`, exactly as `'a'::BOOLEAN`
1302/// does — same failure, same words, because it is the same coercion.
1303#[inline(never)]
1304fn coerce_unknown_literal_to_bool(e: &Expr) -> Result<Value<'static>, EvalError> {
1305    let Expr::Literal(spg_sql::ast::Literal::String(s)) = e else {
1306        unreachable!("guarded by is_unknown_string_literal")
1307    };
1308    cast::cast_value_in(
1309        Value::Text(s.clone().into()),
1310        spg_sql::ast::CastTarget::Bool,
1311        false,
1312    )
1313}
1314
1315/// v7.39 (round 621) — a literal that is plainly not a boolean, and the PG
1316/// type name for it. `NULL` and a bare string literal are deliberately absent:
1317/// neither carries a type of its own, and a boolean connective is a context
1318/// that gives them one.
1319fn non_boolean_literal_type(e: &Expr) -> Option<&'static str> {
1320    use spg_sql::ast::Literal as L;
1321    match e {
1322        Expr::Literal(L::Integer(_)) => Some("integer"),
1323        Expr::Literal(L::Float(_)) => Some("double precision"),
1324        Expr::Literal(L::Numeric { .. } | L::NumericBig(_)) => Some("numeric"),
1325        _ => None,
1326    }
1327}
1328
1329/// v7.39 (round 621) — `AND` / `OR`, evaluated the way PG evaluates them.
1330///
1331/// Round 620 handled the unknown literal here; round 621 adds the part that
1332/// makes `WHERE x <> 0 AND 1/x > 0` work at all. SPG evaluated both sides
1333/// always, so the guard idiom — the whole reason that predicate is written
1334/// that way — raised on the very rows the guard exists to exclude. Measured
1335/// against PG: `false AND (1/0 = 0)` answers `f`, `true OR (1/0 = 0)` answers
1336/// `t`, and a filter guarded that way returns its rows.
1337///
1338/// PG affords that AND still refuses `false AND 1`, because the two happen at
1339/// different times: the operand types are checked during ANALYSIS, before any
1340/// evaluation, and the short circuit is a RUN-TIME decision. Both parts are
1341/// here — the right-hand operand's type is read statically (it is the side
1342/// that may go unevaluated), and only a type that is definitively known and
1343/// definitively not boolean is refused. An unknown type is left alone, so a
1344/// shape the describer cannot type keeps the old behaviour rather than
1345/// earning a spurious error.
1346///
1347/// Order is PG's too, and it is strictly left-first: `(1/0 = 0) AND false`
1348/// raises on both, because the left is evaluated before anything can decide
1349/// that it did not need to be.
1350///
1351/// Out-of-line so it costs `eval_expr` no frame (the round-305 frame cliff).
1352#[inline(never)]
1353fn eval_connective(
1354    lhs: &Expr,
1355    op: BinOp,
1356    rhs: &Expr,
1357    row: &Row<'static>,
1358    ctx: &EvalContext<'_>,
1359) -> Result<Value<'static>, EvalError> {
1360    let side = |e: &Expr| -> Result<Value<'static>, EvalError> {
1361        if is_unknown_string_literal(e) {
1362            coerce_unknown_literal_to_bool(e)
1363        } else {
1364            eval_expr(e, row, ctx)
1365        }
1366    };
1367    let l = side(lhs)?;
1368    // The analysis-time half: refuse a right-hand operand that is plainly not
1369    // boolean, whether or not the short circuit would reach it.
1370    //
1371    // Only a LITERAL is read this way. The first cut asked
1372    // `describe_expr_type` for any expression's type, and it answers
1373    // confidently and wrongly for shapes that matter here — `NULL` comes back
1374    // as text, so `true AND NULL` earned a type error; and a MATCH … AGAINST
1375    // folds internally into an OR over tsvector operands, so full-text search
1376    // stopped working. Three existing pins caught all of it. A literal cannot
1377    // be misread, and it is what PG's own refusals in this area are about.
1378    if let Some(ty) = non_boolean_literal_type(rhs) {
1379        return Err(EvalError::TypeMismatch {
1380            detail: alloc::format!(
1381                "argument of {} must be type boolean, not type {ty}",
1382                if matches!(op, BinOp::And) {
1383                    "AND"
1384                } else {
1385                    "OR"
1386                },
1387            ),
1388        });
1389    }
1390    // Resolving an unknown literal belongs to the same half — it is a
1391    // coercion PG performs while analysing, so `false AND 'a'` says
1392    // `invalid input syntax for type boolean: "a"` rather than answering `f`.
1393    let rhs_resolved = if is_unknown_string_literal(rhs) {
1394        Some(coerce_unknown_literal_to_bool(rhs)?)
1395    } else {
1396        None
1397    };
1398    // The run-time half.
1399    match (op, &l) {
1400        (BinOp::And, Value::Bool(false)) => return Ok(Value::Bool(false)),
1401        (BinOp::Or, Value::Bool(true)) => return Ok(Value::Bool(true)),
1402        _ => {}
1403    }
1404    let r = match rhs_resolved {
1405        Some(v) => v,
1406        None => side(rhs)?,
1407    };
1408    if matches!(op, BinOp::And) {
1409        and_3vl(l, r)
1410    } else {
1411        apply_binary(op, l, r)
1412    }
1413}
1414
1415/// v7.39 (round 346, M1) — the MySQL reading of `AND` / `OR`, out-of-line
1416/// so it costs `eval_expr` no frame (see the round-305 frame cliff).
1417#[inline(never)]
1418fn eval_mysql_connective(
1419    lhs: &Expr,
1420    op: BinOp,
1421    rhs: &Expr,
1422    row: &Row<'static>,
1423    ctx: &EvalContext<'_>,
1424) -> Result<Value<'static>, EvalError> {
1425    let l = as_mysql_truth(eval_expr(lhs, row, ctx)?)?;
1426    let r = as_mysql_truth(eval_expr(rhs, row, ctx)?)?;
1427    apply_mysql_connective(op, l, r)
1428}
1429
1430/// v7.39 (round 407) — apply a MySQL logical connective (`AND` / `OR` /
1431/// `XOR`) to two operands already reduced to truth values (`Bool` or
1432/// `Null`). AND / OR reuse the dialect-blind `apply_binary`; `XOR` is
1433/// MySQL-only (no `apply_binary` arm) and computed here: NULL on either
1434/// side yields NULL, otherwise the exclusive-or of the two truth values.
1435pub(crate) fn apply_mysql_connective(
1436    op: BinOp,
1437    l: Value<'static>,
1438    r: Value<'static>,
1439) -> Result<Value<'static>, EvalError> {
1440    if op == BinOp::LogicalXor {
1441        return Ok(match (&l, &r) {
1442            (Value::Bool(a), Value::Bool(b)) => Value::Bool(a != b),
1443            _ => Value::Null,
1444        });
1445    }
1446    apply_binary(op, l, r)
1447}
1448
1449#[inline(never)]
1450pub(crate) fn as_mysql_truth(v: Value<'static>) -> Result<Value<'static>, EvalError> {
1451    Ok(match v {
1452        Value::Null => Value::Null,
1453        other => Value::Bool(predicate_is_true(&other, "AND", true)?),
1454    })
1455}
1456
1457/// The MySQL reading of `NOT`, likewise out-of-line.
1458#[inline(never)]
1459fn mysql_not(v: &Value<'_>) -> Result<Value<'static>, EvalError> {
1460    Ok(Value::Bool(!predicate_is_true(v, "NOT", true)?))
1461}
1462
1463/// v7.39 (round 346, M1) — is this value TRUE, in a position that wants a
1464/// truth value (WHERE / CASE WHEN / NOT / AND / OR / HAVING / ON)?
1465///
1466/// The engine used to write `matches!(v, Value::Bool(true))` at every such
1467/// position, so anything that was not already a boolean silently read as
1468/// FALSE. `SELECT CASE WHEN 1 THEN 'a' END` answered NULL and — far worse —
1469/// `SELECT … WHERE 1` returned **no rows at all**. Neither dialect does
1470/// that: MariaDB 11 takes any non-zero number as true, and PG 18.4 raises
1471/// `argument of WHERE must be type boolean, not type integer`.
1472///
1473/// NULL is not true (three-valued logic) and is not an error in either.
1474pub(crate) fn predicate_is_true(v: &Value<'_>, kw: &str, mysql: bool) -> Result<bool, EvalError> {
1475    match v {
1476        Value::Bool(b) => Ok(*b),
1477        Value::Null => Ok(false),
1478        _ if mysql => Ok(mysql_truthy(v)),
1479        // PG resolves a bare literal in this position through boolean
1480        // INPUT, so `CASE WHEN 'true'` is legal and `'abc'` is not.
1481        Value::Text(t) => match crate::eval::cast::cast_value(
1482            Value::text(t.to_string()),
1483            spg_sql::ast::CastTarget::Bool,
1484        )? {
1485            Value::Bool(b) => Ok(b),
1486            _ => Ok(false),
1487        },
1488        other => Err(EvalError::TypeMismatch {
1489            detail: alloc::format!(
1490                "argument of {kw} must be type boolean, not type {}",
1491                crate::eval::strings::pg_typeof_name(other)
1492            ),
1493        }),
1494    }
1495}
1496
1497/// MariaDB 11's reading, measured: a number is true when it is not zero
1498/// (`-1` and `0.5` are both true); a string contributes its LEADING
1499/// number, so `'1abc'` is true while `'abc'` and `''` are false.
1500fn mysql_truthy(v: &Value<'_>) -> bool {
1501    match v {
1502        Value::Bool(b) => *b,
1503        Value::Null => false,
1504        Value::SmallInt(n) => *n != 0,
1505        Value::Int(n) => *n != 0,
1506        Value::BigInt(n) => *n != 0,
1507        Value::Float(f) => *f != 0.0,
1508        Value::Real(f) => *f != 0.0,
1509        Value::Numeric { scaled, .. } => *scaled != 0,
1510        Value::Text(t) => mysql_leading_number(t) != 0.0,
1511        Value::BpChar(t) => mysql_leading_number(t) != 0.0,
1512        // Everything else converts to a non-zero number in MariaDB (a
1513        // DATE reads as its YYYYMMDD digits, for one).
1514        _ => true,
1515    }
1516}
1517
1518/// The leading numeric prefix of a string, MySQL-style: `'1abc'` is 1,
1519/// `'abc'` and `''` are 0.
1520#[inline(never)]
1521pub(crate) fn mysql_leading_number(s: &str) -> f64 {
1522    let t = s.trim_start();
1523    let mut end = 0usize;
1524    let mut seen_dot = false;
1525    let mut seen_digit = false;
1526    // v7.39 (round 351, M11) — the exponent form counts: MariaDB reads
1527    // `'1e3'` as 1000 and `'1.5e2'` as 150 (measured). A trailing `e`
1528    // with no digits after it is not part of the number (`'1e'` is 1).
1529    let mut seen_exp = false;
1530    let mut exp_at = 0usize;
1531    for (i, c) in t.char_indices() {
1532        match c {
1533            '-' | '+' if i == 0 => {}
1534            '-' | '+' if seen_exp && i == exp_at + 1 => {}
1535            '0'..='9' => seen_digit = true,
1536            '.' if !seen_dot && !seen_exp => seen_dot = true,
1537            'e' | 'E' if seen_digit && !seen_exp => {
1538                seen_exp = true;
1539                exp_at = i;
1540            }
1541            _ => break,
1542        }
1543        end = i + c.len_utf8();
1544    }
1545    if !seen_digit {
1546        return 0.0;
1547    }
1548    // Trim an exponent that never got its digits.
1549    let mut text = &t[..end];
1550    while !text.is_empty() && text.parse::<f64>().is_err() {
1551        text = &text[..text.len() - 1];
1552    }
1553    text.parse::<f64>().unwrap_or(0.0)
1554}
1555
1556/// v7.39 (read01 ruleutils.c) — resolve a relation name to its synthetic
1557/// oid: user tables in the 16384+ band (table_names order), views at
1558/// 32768+, and the synthesised system catalogs at their REAL PG oids.
1559/// `None` when the name is unknown (the caller keeps the legacy text
1560/// behaviour so `'anything'::regclass::text` still round-trips).
1561pub(crate) fn regclass_name_to_oid(cat: &spg_storage::Catalog, bare: &str) -> Option<i64> {
1562    // v7.39 (round 337, V62) — an INDEX and a SEQUENCE are relations too:
1563    // both have a `pg_class` row, so both answer to `::regclass` in PG.
1564    // v7.39 (round 338, V64) — and the bands live in ONE allocator now,
1565    // shared with the catalog synths, so `pg_class.oid = 'x'::regclass`
1566    // holds for every kind rather than only for tables.
1567    if let Some(oid) = crate::system_catalog::relation_oid(cat, bare) {
1568        return Some(oid);
1569    }
1570    Some(match bare {
1571        "pg_type" => 1247,
1572        "pg_attribute" => 1249,
1573        "pg_proc" => 1255,
1574        "pg_class" => 1259,
1575        "pg_database" => 1262,
1576        "pg_constraint" => 2606,
1577        "pg_index" => 2610,
1578        "pg_namespace" => 2615,
1579        // v7.39 (round 650) — the text-search catalogs. This list is a
1580        // hand-kept subset of `CATALOG_RELATIONS`, which is why adding a
1581        // catalog there was not enough for `'pg_ts_config'::regclass`.
1582        "pg_ts_config" => 3602,
1583        "pg_ts_config_map" => 3603,
1584        "pg_ts_dict" => 3600,
1585        "pg_ts_parser" => 3601,
1586        "pg_ts_template" => 3764,
1587        _ => return None,
1588    })
1589}
1590
1591/// v7.39 (round 263) — crate-visible wrapper so the write path can
1592/// relabel + coerce a value into a composite column's declared type.
1593pub(crate) fn apply_composite_cast_pub(
1594    v: Value<'static>,
1595    comp: &spg_storage::CompositeDef,
1596    cat: Option<&spg_storage::Catalog>,
1597) -> Result<Value<'static>, EvalError> {
1598    apply_composite_cast_in(v, comp, cat)
1599}
1600
1601/// v7.39 (round 264) — resolve one field's value, recursing when the
1602/// field is itself a COMPOSITE. Without this a nested field kept the
1603/// inner record's TEXT rendering, so `(x).inner.street` errored and
1604/// `row_to_json` nested a string rather than an object.
1605fn coerce_composite_field(
1606    val: Value<'static>,
1607    fname: &str,
1608    fty: spg_storage::DataType,
1609    user_ty: Option<&str>,
1610    cat: Option<&spg_storage::Catalog>,
1611) -> Result<Value<'static>, EvalError> {
1612    if matches!(val, Value::Null) {
1613        return Ok(val);
1614    }
1615    if let Some(tn) = user_ty
1616        && let Some(inner) = cat.and_then(|c| c.composite_types().get(tn))
1617    {
1618        return apply_composite_cast_in(val, inner, cat);
1619    }
1620    crate::conversions::coerce_value(val, fty, fname, 0).map_err(|e| EvalError::TypeMismatch {
1621        detail: alloc::format!("{e}"),
1622    })
1623}
1624
1625fn apply_composite_cast(
1626    v: Value<'static>,
1627    comp: &spg_storage::CompositeDef,
1628) -> Result<Value<'static>, EvalError> {
1629    apply_composite_cast_in(v, comp, None)
1630}
1631
1632fn apply_composite_cast_in(
1633    v: Value<'static>,
1634    comp: &spg_storage::CompositeDef,
1635    cat: Option<&spg_storage::Catalog>,
1636) -> Result<Value<'static>, EvalError> {
1637    match v {
1638        Value::Null => Ok(Value::Null),
1639        Value::Composite(fields) => {
1640            if fields.len() != comp.fields.len() {
1641                // PG reports the SHAPE mismatch as a plain cast refusal.
1642                return Err(EvalError::TypeMismatch {
1643                    detail: alloc::format!("cannot cast type record to {}", comp.name),
1644                });
1645            }
1646            // v7.39 (round 263) — relabel AND coerce: this branch only
1647            // renamed the fields, so `ROW('x','notanint')::addr` kept the
1648            // text in an int field and PG's input error never fired.
1649            let mut out: alloc::vec::Vec<(alloc::string::String, Value<'static>)> =
1650                alloc::vec::Vec::with_capacity(comp.fields.len());
1651            for (i, ((name, fty), (_, val))) in comp.fields.iter().zip(fields).enumerate() {
1652                let ut = comp.field_user_types.get(i).and_then(Option::as_deref);
1653                let coerced = coerce_composite_field(val, name, *fty, ut, cat)?;
1654                out.push((name.clone(), coerced));
1655            }
1656            Ok(Value::Composite(out))
1657        }
1658        Value::Text(s) => {
1659            let raw = parse_record_text(s.as_ref()).ok_or_else(|| EvalError::TypeMismatch {
1660                detail: alloc::format!("malformed record literal: \"{s}\""),
1661            })?;
1662            if raw.len() != comp.fields.len() {
1663                return Err(EvalError::TypeMismatch {
1664                    detail: alloc::format!("malformed record literal: \"{s}\""),
1665                });
1666            }
1667            let mut out: alloc::vec::Vec<(alloc::string::String, Value<'static>)> =
1668                alloc::vec::Vec::with_capacity(raw.len());
1669            for (i, ((fname, fty), field_text)) in comp.fields.iter().zip(raw).enumerate() {
1670                let ut = comp.field_user_types.get(i).and_then(Option::as_deref);
1671                let val = match field_text {
1672                    None => Value::Null,
1673                    Some(t) => coerce_composite_field(Value::text(t), fname, *fty, ut, cat)?,
1674                };
1675                out.push((fname.clone(), val));
1676            }
1677            Ok(Value::Composite(out))
1678        }
1679        other => Err(EvalError::TypeMismatch {
1680            detail: alloc::format!(
1681                "cannot cast {} to composite type \"{}\"",
1682                crate::conversions::pg_type_name_for_error_opt(other.data_type()),
1683                comp.name
1684            ),
1685        }),
1686    }
1687}
1688
1689/// Split PG's record text `(f1,f2,...)` into per-field raw strings
1690/// (None = empty field = NULL). Double quotes wrap fields containing
1691/// metacharacters; `""` inside is a literal quote; a backslash escapes
1692/// the next character.
1693fn parse_record_text(s: &str) -> Option<alloc::vec::Vec<Option<alloc::string::String>>> {
1694    let t = s.trim();
1695    let inner = t.strip_prefix('(')?.strip_suffix(')')?;
1696    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
1697    let chars: alloc::vec::Vec<char> = inner.chars().collect();
1698    let mut field = alloc::string::String::new();
1699    let mut quoted_seen = false;
1700    let mut i = 0usize;
1701    let mut in_quotes = false;
1702    loop {
1703        if i >= chars.len() {
1704            if in_quotes {
1705                return None;
1706            }
1707            out.push(if field.is_empty() && !quoted_seen {
1708                None
1709            } else {
1710                Some(field.clone())
1711            });
1712            break;
1713        }
1714        let c = chars[i];
1715        if in_quotes {
1716            match c {
1717                '"' if chars.get(i + 1) == Some(&'"') => {
1718                    field.push('"');
1719                    i += 2;
1720                }
1721                '"' => {
1722                    in_quotes = false;
1723                    i += 1;
1724                }
1725                '\\' => {
1726                    field.push(*chars.get(i + 1)?);
1727                    i += 2;
1728                }
1729                _ => {
1730                    field.push(c);
1731                    i += 1;
1732                }
1733            }
1734        } else {
1735            match c {
1736                '"' => {
1737                    in_quotes = true;
1738                    quoted_seen = true;
1739                    i += 1;
1740                }
1741                ',' => {
1742                    out.push(if field.is_empty() && !quoted_seen {
1743                        None
1744                    } else {
1745                        Some(core::mem::take(&mut field))
1746                    });
1747                    quoted_seen = false;
1748                    i += 1;
1749                }
1750                '\\' => {
1751                    field.push(*chars.get(i + 1)?);
1752                    i += 2;
1753                }
1754                _ => {
1755                    field.push(c);
1756                    i += 1;
1757                }
1758            }
1759        }
1760    }
1761    Some(out)
1762}
1763
1764fn apply_enum_cast<'a>(
1765    v: Value<'a>,
1766    en: &spg_storage::EnumDef,
1767    name: &str,
1768) -> Result<Value<'a>, EvalError> {
1769    match &v {
1770        Value::Null => Ok(v),
1771        Value::Text(s) => {
1772            if en.labels.iter().any(|l| l.as_str() == s.as_ref()) {
1773                Ok(v)
1774            } else {
1775                Err(EvalError::TypeMismatch {
1776                    detail: alloc::format!("invalid input value for enum {name}: {s:?}"),
1777                })
1778            }
1779        }
1780        other => Err(EvalError::TypeMismatch {
1781            detail: alloc::format!(
1782                "cannot cast {} to enum {name}",
1783                crate::conversions::pg_type_name_for_error_opt(other.data_type())
1784            ),
1785        }),
1786    }
1787}
1788
1789/// v7.39 (read01 utils/adt, enum.c) — enum_first / enum_last /
1790/// enum_range resolved from the argument's STATIC enum type (an explicit
1791/// `::enumtype` cast or a column's `ColumnSchema.user_enum_type`) over the
1792/// catalog's member order. Returns None when no argument names a known
1793/// enum, letting the generic function path produce its usual error.
1794/// Out-of-line (`inline(never)`) so the sizable locals don't land in
1795/// `eval_expr`'s recursion frame.
1796/// Enum-ness lives outside the DataType lattice: the witness for "this
1797/// expression is enum-typed" is an explicit `::enumtype` cast or a column
1798/// whose `ColumnSchema.user_enum_type` is set.
1799/// v7.39 (round 258) — crate-visible wrapper so the projection builder
1800/// can keep an expression's enum identity (see `select.rs`).
1801pub(crate) fn expr_enum_type_name_pub<'e>(
1802    e: &'e Expr,
1803    columns: &'e [ColumnSchema],
1804) -> Option<&'e str> {
1805    expr_enum_type_name(e, columns)
1806}
1807
1808/// v7.39 (round 425) — the fractional-seconds precision a projected
1809/// expression should RENDER with: the widest declared precision among the
1810/// MySQL temporal columns it reads. `MAX(d3)` and `d3 + INTERVAL 1 SECOND`
1811/// both keep `d3`'s three digits, as MariaDB does. `None` when the
1812/// expression touches no such column, which leaves PG rendering untouched.
1813///
1814/// Residual (recorded, not modelled): MariaDB also WIDENS the precision from
1815/// some operands — `DATE_ADD(d3, INTERVAL 1 MICROSECOND)` prints six digits
1816/// there. Taking the max over referenced columns covers the common shapes
1817/// and never narrows below the source column.
1818pub(crate) fn expr_mysql_fsp(e: &Expr, columns: &[ColumnSchema]) -> Option<u8> {
1819    fn walk(e: &Expr, columns: &[ColumnSchema], best: &mut Option<u8>) {
1820        match e {
1821            Expr::Column(c) => {
1822                if let Some(f) = columns
1823                    .iter()
1824                    .find(|col| col.name == c.name)
1825                    .and_then(|col| col.mysql_fsp)
1826                {
1827                    *best = Some(best.map_or(f, |b: u8| b.max(f)));
1828                }
1829            }
1830            Expr::Binary { lhs, rhs, .. } => {
1831                walk(lhs, columns, best);
1832                walk(rhs, columns, best);
1833            }
1834            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } => walk(expr, columns, best),
1835            Expr::FunctionCall { args, .. } => {
1836                for a in args {
1837                    walk(a, columns, best);
1838                }
1839            }
1840            Expr::Case {
1841                operand,
1842                branches,
1843                else_branch,
1844            } => {
1845                if let Some(o) = operand.as_deref() {
1846                    walk(o, columns, best);
1847                }
1848                for (w, t) in branches {
1849                    walk(w, columns, best);
1850                    walk(t, columns, best);
1851                }
1852                if let Some(el) = else_branch.as_deref() {
1853                    walk(el, columns, best);
1854                }
1855            }
1856            _ => {}
1857        }
1858    }
1859    let mut best = None;
1860    walk(e, columns, &mut best);
1861    best
1862}
1863
1864/// v7.39 (round 467) — is this expression MySQL-UNSIGNED?
1865///
1866/// MySQL decides unsignedness statically, from the expression's type, not
1867/// from the value it happens to produce. Measured on MariaDB 11: `SUM(a) -
1868/// 100` answers -99 even though `a` is `INT UNSIGNED`, because SUM's result
1869/// type is not unsigned; `a - 5` on the same column raises 1690. So this
1870/// walks the expression the way MySQL's type resolution does.
1871///
1872/// A cast names its target `unsigned` (the parser lowercases MySQL's
1873/// `CAST(x AS UNSIGNED)` into `CastTarget::Named`). Arithmetic is unsigned
1874/// when EITHER operand is — that is MySQL's rule, and it is why `1 - b`
1875/// raises while `5 - a` does not: both are unsigned expressions, but only
1876/// the first has a negative result.
1877///
1878/// Deliberately NOT unsigned: unary minus (MariaDB answers -1 for
1879/// `-CAST(1 AS UNSIGNED)`), and every function result including the
1880/// aggregates. Both measured.
1881pub(crate) fn expr_is_mysql_unsigned(e: &Expr, columns: &[ColumnSchema]) -> bool {
1882    match e {
1883        Expr::Column(c) => columns
1884            .iter()
1885            .find(|col| col.name == c.name)
1886            .is_some_and(|col| col.is_unsigned),
1887        Expr::Cast {
1888            target: CastTarget::Named(n),
1889            ..
1890        } => n.eq_ignore_ascii_case("unsigned"),
1891        Expr::Binary {
1892            lhs,
1893            op: BinOp::Add | BinOp::Sub | BinOp::Mul,
1894            rhs,
1895        } => expr_is_mysql_unsigned(lhs, columns) || expr_is_mysql_unsigned(rhs, columns),
1896        _ => false,
1897    }
1898}
1899
1900/// v7.39 (round 467) — MySQL arithmetic over an UNSIGNED operand, with
1901/// MySQL's range check.
1902///
1903/// `INT UNSIGNED` columns holding 1 and 5 made `a - b` answer **-4** in a
1904/// MySQL session. MariaDB raises `ERROR 1690 (22003): BIGINT UNSIGNED value
1905/// is out of range`. A negative answer where the server promises a
1906/// non-negative one is the kind of thing an application stores back into
1907/// the same column, so it was silent and wrong in the worst direction.
1908///
1909/// The check runs in i128 so the subtraction that underflows is observed
1910/// rather than wrapped, and it only fires when the expression is unsigned
1911/// AND both operands are integers — a NUMERIC or float operand takes the
1912/// ordinary path, as it does in MySQL.
1913///
1914/// `#[inline(never)]`: this is called from the recursive evaluator's
1915/// hottest frame, which already sits against the stack guard.
1916#[inline(never)]
1917fn apply_binary_mysql_unsigned(
1918    op: BinOp,
1919    lhs: &Expr,
1920    rhs: &Expr,
1921    l: Value<'static>,
1922    r: Value<'static>,
1923    ctx: &EvalContext,
1924) -> Result<Value<'static>, EvalError> {
1925    if ctx.mysql_dialect
1926        && matches!(op, BinOp::Add | BinOp::Sub | BinOp::Mul)
1927        && let Some(a) = mysql_int_operand(&l)
1928        && let Some(b) = mysql_int_operand(&r)
1929        && (expr_is_mysql_unsigned(lhs, ctx.columns) || expr_is_mysql_unsigned(rhs, ctx.columns))
1930    {
1931        let out = match op {
1932            BinOp::Add => a.checked_add(b),
1933            BinOp::Sub => a.checked_sub(b),
1934            _ => a.checked_mul(b),
1935        };
1936        let in_range = out.is_some_and(|v| (0..=i128::from(u64::MAX)).contains(&v));
1937        if !in_range {
1938            // MariaDB names the offending expression in the message, with
1939            // minimal parentheses — `a * 0 - 1`, not `((a * 0) - 1)`.
1940            // `pretty_expr` is the deparser that already produces that
1941            // shape. Residual, recorded rather than faked: MariaDB writes
1942            // its columns fully qualified in backticks
1943            // (`db`.`tbl`.`col`), and the database name is not something
1944            // the evaluation context carries.
1945            return Err(EvalError::TypeMismatch {
1946                detail: alloc::format!(
1947                    "BIGINT UNSIGNED value is out of range in '{}'",
1948                    spg_sql::ast::pretty_expr_mysql(&Expr::Binary {
1949                        lhs: alloc::boxed::Box::new(lhs.clone()),
1950                        op,
1951                        rhs: alloc::boxed::Box::new(rhs.clone()),
1952                    })
1953                ),
1954            });
1955        }
1956    }
1957    let out = apply_binary_in(op, l, r, ctx.mysql_dialect);
1958    // v7.39 (round 503) — MariaDB answers NULL for division / modulo by
1959    // zero; SPG raised.
1960    //
1961    // Measured against MariaDB 11: `SELECT 1/0`, `SELECT 5 DIV 0` and
1962    // `SELECT 5 % 0` are all NULL — and they are NULL under the DEFAULT
1963    // sql_mode too, which contains `ERROR_FOR_DIVISION_BY_ZERO`. That flag
1964    // governs WRITES, not the expression: the division evaluates to NULL,
1965    // and a strict-mode INSERT of that result is what raises 1365.
1966    //
1967    // The rule is therefore the DIALECT's, not the mode's: in a MySQL
1968    // session the expression is NULL. It is deliberately not gated on
1969    // `mysql_strict` — an earlier cut of this was, and the gate fired only
1970    // because the probe's context happens to carry no engine, which a
1971    // later round attaching one would have silently reversed.
1972    //
1973    // RESIDUAL, recorded rather than faked: MariaDB's strict-mode INSERT
1974    // of a division by zero raises 1365 and its non-strict INSERT stores
1975    // NULL. SPG's INSERT path evaluates elsewhere and still raises, so it
1976    // matches strict and diverges from non-strict. Closing that needs the
1977    // expression to know it is in a write, which nothing here carries.
1978    if ctx.mysql_dialect && matches!(out, Err(EvalError::DivisionByZero)) {
1979        return Ok(Value::Null);
1980    }
1981    out
1982}
1983
1984/// The integer an operand contributes to the unsigned range check, or
1985/// `None` when it is not an integer at all (NULL, text, NUMERIC, float).
1986fn mysql_int_operand(v: &Value<'_>) -> Option<i128> {
1987    match v {
1988        Value::SmallInt(n) => Some(i128::from(*n)),
1989        Value::Int(n) => Some(i128::from(*n)),
1990        Value::BigInt(n) => Some(i128::from(*n)),
1991        // v7.39 (round 471) — a BIGINT UNSIGNED cell is stored as Numeric
1992        // with scale 0, so the range check has to see it as the integer it
1993        // is. Without this arm the column's own type moved it out of reach
1994        // of round 467's guard and `c - 5` went back to answering -4.
1995        Value::Numeric { scaled, scale, .. } if *scale == 0 => Some(*scaled),
1996        _ => None,
1997    }
1998}
1999
2000fn expr_enum_type_name<'e>(e: &'e Expr, columns: &'e [ColumnSchema]) -> Option<&'e str> {
2001    match e {
2002        Expr::Cast {
2003            target: CastTarget::Named(n),
2004            ..
2005        } => Some(n.as_str()),
2006        Expr::Column(c) => columns
2007            .iter()
2008            .find(|col| col.name == c.name)
2009            // v7.39 (round 259) — a DOMAIN column carries its name in its
2010            // own field; both are "the user type this column is declared
2011            // as", which is what the callers (enum-order comparison,
2012            // pg_typeof) want. Callers gate on the catalog, so a name that
2013            // is one kind never resolves as the other.
2014            .and_then(|col| {
2015                col.user_enum_type
2016                    .as_deref()
2017                    .or(col.user_domain_type.as_deref())
2018            }),
2019        _ => None,
2020    }
2021}
2022
2023/// v7.39 (enum order knife) — the member-label list for an enum-typed
2024/// expression, or None when the expression carries no enum witness or the
2025/// name is not a known enum. The returned slice borrows the catalog.
2026pub(crate) fn expr_enum_labels<'c>(
2027    e: &Expr,
2028    columns: &[ColumnSchema],
2029    catalog: Option<&'c spg_storage::Catalog>,
2030) -> Option<&'c [String]> {
2031    let name = expr_enum_type_name(e, columns)?;
2032    catalog
2033        .and_then(|cat| cat.enum_types().get(name))
2034        .map(|en| en.labels.as_slice())
2035}
2036
2037/// v7.39 (enum order knife) — compare two enum labels by member order.
2038/// None when either side is not Text or not a member (caller falls back to
2039/// the generic comparison, so a stray value never panics or misorders
2040/// silently differently from before).
2041pub(crate) fn enum_ord_cmp(
2042    labels: &[String],
2043    a: &Value<'_>,
2044    b: &Value<'_>,
2045) -> Option<core::cmp::Ordering> {
2046    let pos = |v: &Value<'_>| -> Option<usize> {
2047        match v {
2048            Value::Text(s) => labels.iter().position(|l| l.as_str() == s.as_ref()),
2049            _ => None,
2050        }
2051    };
2052    Some(pos(a)?.cmp(&pos(b)?))
2053}
2054
2055/// v7.39 (enum order knife) — Binary-comparison hook: when either side's
2056/// static type witnesses an enum and both runtime values are member labels,
2057/// compare by member order (PG's enumsortorder semantics). Out-of-line to
2058/// keep `eval_expr`'s recursion frame small.
2059#[inline(never)]
2060fn enum_compare_hook(
2061    op: BinOp,
2062    lhs: &Expr,
2063    rhs: &Expr,
2064    l: &Value<'_>,
2065    r: &Value<'_>,
2066    ctx: &EvalContext<'_>,
2067) -> Option<Result<Value<'static>, EvalError>> {
2068    let cat = ctx.catalog?;
2069    if cat.enum_types().is_empty() {
2070        return None;
2071    }
2072    let labels = expr_enum_labels(lhs, ctx.columns, ctx.catalog)
2073        .or_else(|| expr_enum_labels(rhs, ctx.columns, ctx.catalog))?;
2074    let ord = enum_ord_cmp(labels, l, r)?;
2075    let b = match op {
2076        BinOp::Eq => ord == core::cmp::Ordering::Equal,
2077        BinOp::NotEq => ord != core::cmp::Ordering::Equal,
2078        BinOp::Lt => ord == core::cmp::Ordering::Less,
2079        BinOp::LtEq => ord != core::cmp::Ordering::Greater,
2080        BinOp::Gt => ord == core::cmp::Ordering::Greater,
2081        BinOp::GtEq => ord != core::cmp::Ordering::Less,
2082        _ => return None,
2083    };
2084    Some(Ok(Value::Bool(b)))
2085}
2086
2087/// v7.39 (round 693) — Binary-comparison hook for a declared collation, the
2088/// last shape F36 left open: `loc BETWEEN 'a' AND 'd'` returns a different
2089/// ROW SET under `en_US.utf8` than under byte order, not merely a different
2090/// order.
2091///
2092/// It sits beside [`enum_compare_hook`] because it is the same kind of fact
2093/// — something about the operand COLUMNS that `compare` cannot look up from
2094/// two values — and takes the same two protections: `#[inline(never)]`, so
2095/// `eval_expr`'s recursion frame does not grow (the comment at the call site
2096/// records a fourth `||` there tipping the 768 KiB guard on its own), and
2097/// the caller's Text/Text gate, so no integer comparison reaches it.
2098///
2099/// EQUALITY is deliberately not handled. PG18's `en_US.utf8` is
2100/// deterministic, so `=`, `<>`, `LIKE`, `IN` and `count(DISTINCT …)` give
2101/// byte-equality's answer — measured, all five. Only the ordering operators
2102/// change, and `least`/`greatest` follow them through their own comparator.
2103#[inline(never)]
2104fn collate_compare_hook(
2105    op: BinOp,
2106    lhs: &Expr,
2107    rhs: &Expr,
2108    l: &Value<'_>,
2109    r: &Value<'_>,
2110    ctx: &EvalContext<'_>,
2111) -> Option<Result<Value<'static>, EvalError>> {
2112    if !matches!(op, BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq) {
2113        return None;
2114    }
2115    let (Value::Text(a), Value::Text(b)) = (l, r) else {
2116        return None;
2117    };
2118    let resolve = |c: &spg_sql::ast::ColumnName| -> Option<alloc::string::String> {
2119        let pos = find_column_pos(c, ctx)?;
2120        ctx.columns.get(pos)?.collation_name.clone()
2121    };
2122    let derived = crate::collate_derive::derive(lhs, &resolve)
2123        .combine_pub(crate::collate_derive::derive(rhs, &resolve));
2124    if let Some((x, y)) = derived.conflict() {
2125        return Some(Err(EvalError::TypeMismatch {
2126            detail: alloc::format!(
2127                "collation mismatch between implicit collations \"{x}\" and \"{y}\""
2128            ),
2129        }));
2130    }
2131    let ord = crate::collate::compare(derived.name()?, a, b)?;
2132    let b = match op {
2133        BinOp::Lt => ord == core::cmp::Ordering::Less,
2134        BinOp::LtEq => ord != core::cmp::Ordering::Greater,
2135        BinOp::Gt => ord == core::cmp::Ordering::Greater,
2136        BinOp::GtEq => ord != core::cmp::Ordering::Less,
2137        _ => return None,
2138    };
2139    Some(Ok(Value::Bool(b)))
2140}
2141
2142/// v7.39 (round 693) — the collation `least`/`greatest` should compare by,
2143/// derived across every argument the same way a comparison's two operands
2144/// are. `None` keeps byte order, which is right for arguments that declare
2145/// nothing.
2146#[inline(never)]
2147fn greatest_least_collation(args: &[Expr], ctx: &EvalContext<'_>) -> Option<alloc::string::String> {
2148    let resolve = |c: &spg_sql::ast::ColumnName| -> Option<alloc::string::String> {
2149        let pos = find_column_pos(c, ctx)?;
2150        ctx.columns.get(pos)?.collation_name.clone()
2151    };
2152    let derived = args
2153        .iter()
2154        .fold(crate::collate_derive::Derived::None, |acc, a| {
2155            acc.combine_pub(crate::collate_derive::derive(a, &resolve))
2156        });
2157    derived
2158        .name()
2159        .filter(|n| crate::collate::is_supported(n))
2160        .map(alloc::string::ToString::to_string)
2161}
2162
2163/// v7.39 (round 704) — rewrite a comparison's operator-not-found error when
2164/// the operand at fault is an UNKNOWN string literal against a numeric-family
2165/// value. PG commits such a literal to the other side's type before comparing,
2166/// so its error is the input function's — `invalid input syntax for type
2167/// integer: "abc"` — not `operator does not exist: integer = text`. An
2168/// explicit `::text` operand keeps the operator error (`1 IS DISTINCT FROM
2169/// 'a'::text`, measured on PG18), which is precisely the distinction two
2170/// `Value`s cannot carry: the first cut of this round rewrote inside
2171/// `binop::compare` and the r238 pin plus corpus 19 caught it the same day.
2172///
2173/// Error-path only — a comparison that succeeds never calls this — so the
2174/// 35.6 %-of-self-time note on `compare` is untouched.
2175#[cold]
2176#[inline(never)]
2177fn unknown_literal_cmp_error(
2178    err: EvalError,
2179    lhs: &Expr,
2180    rhs: &Expr,
2181    lv: &Value<'_>,
2182    rv: &Value<'_>,
2183) -> EvalError {
2184    let EvalError::TypeMismatch { detail } = &err else {
2185        return err;
2186    };
2187    // Two spellings of the same fall-through: `compare`'s operator error,
2188    // and the owned numeric path's conversion error (`f = 'y'` reaches
2189    // "cannot convert text to FLOAT"). Both mean the literal failed to
2190    // lift; neither is what PG says about an unknown literal.
2191    if !detail.starts_with("operator does not exist")
2192        && !detail.starts_with("cannot convert text to")
2193    {
2194        return err;
2195    }
2196    let numeric = |v: &Value<'_>| {
2197        matches!(
2198            v.data_type(),
2199            Some(
2200                spg_storage::DataType::SmallInt
2201                    | spg_storage::DataType::Int
2202                    | spg_storage::DataType::BigInt
2203                    | spg_storage::DataType::Float
2204                    | spg_storage::DataType::Real
2205                    | spg_storage::DataType::Numeric { .. }
2206            )
2207        )
2208    };
2209    let rewrite = |s: &Value<'_>, other: &Value<'_>| -> Option<EvalError> {
2210        let Value::Text(text) = s else { return None };
2211        let dt = other.data_type()?;
2212        Some(EvalError::TypeMismatch {
2213            detail: alloc::format!(
2214                "invalid input syntax for type {}: \"{text}\"",
2215                crate::conversions::pg_type_name_for_error(dt)
2216            ),
2217        })
2218    };
2219    if is_unknown_string_literal(lhs)
2220        && numeric(rv)
2221        && let Some(e) = rewrite(lv, rv)
2222    {
2223        return e;
2224    }
2225    if is_unknown_string_literal(rhs)
2226        && numeric(lv)
2227        && let Some(e) = rewrite(rv, lv)
2228    {
2229        return e;
2230    }
2231    err
2232}
2233
2234fn enum_arg_type_name<'e>(args: &'e [Expr], ctx: &EvalContext<'e>) -> Option<&'e str> {
2235    args.iter()
2236        .find_map(|a| expr_enum_type_name(a, ctx.columns))
2237        .filter(|n| {
2238            ctx.catalog
2239                .is_some_and(|cat| cat.enum_types().contains_key(*n))
2240        })
2241}
2242
2243/// Cheap value-free precheck so `eval_expr`'s recursion frame carries no
2244/// binding for the enum path (stack-depth guard budget).
2245#[inline(never)]
2246fn enum_introspection_applies(args: &[Expr], ctx: &EvalContext<'_>) -> bool {
2247    enum_arg_type_name(args, ctx).is_some()
2248}
2249
2250#[inline(never)]
2251fn eval_enum_introspection(
2252    name: &str,
2253    args: &[Expr],
2254    row: &Row<'static>,
2255    ctx: &EvalContext<'_>,
2256) -> Result<Value<'static>, EvalError> {
2257    let Some(en) = enum_arg_type_name(args, ctx)
2258        .and_then(|n| ctx.catalog.and_then(|cat| cat.enum_types().get(n)))
2259    else {
2260        // The precheck guarantees this arm is unreachable; keep a typed
2261        // error rather than a panic if the two ever drift.
2262        return Err(EvalError::TypeMismatch {
2263            detail: "could not determine polymorphic type".into(),
2264        });
2265    };
2266    let labels = &en.labels;
2267    if labels.is_empty() {
2268        return Ok(Value::Null);
2269    }
2270    if name.eq_ignore_ascii_case("enum_first") {
2271        return Ok(Value::text(labels[0].clone()));
2272    }
2273    if name.eq_ignore_ascii_case("enum_last") {
2274        return Ok(Value::text(labels[labels.len() - 1].clone()));
2275    }
2276    // enum_range(NULL) = all; enum_range(lo, hi) slices inclusively,
2277    // NULL bound = open end (PG).
2278    let pos_of = |v: &Value<'_>| -> Option<usize> {
2279        match v {
2280            Value::Text(s) => labels.iter().position(|l| l == s.as_ref()),
2281            _ => None,
2282        }
2283    };
2284    let (lo, hi) = if args.len() == 2 {
2285        let a = eval_expr(&args[0], row, ctx)?;
2286        let b = eval_expr(&args[1], row, ctx)?;
2287        (
2288            pos_of(&a).unwrap_or(0),
2289            pos_of(&b).unwrap_or(labels.len() - 1),
2290        )
2291    } else {
2292        (0, labels.len() - 1)
2293    };
2294    let out: alloc::vec::Vec<Option<String>> = labels
2295        .get(lo..=hi)
2296        .unwrap_or(&[])
2297        .iter()
2298        .map(|l| Some(l.clone()))
2299        .collect();
2300    Ok(Value::TextArray(out))
2301}
2302
2303/// Apply one `[index]` subscript to a value — the single-step semantics shared
2304/// by 1-D array elements and JSON path access (`j['a']`, `j[0]`). NULL target
2305/// or index → NULL; a 1-based integer indexes a 1-D array (out of range → NULL,
2306/// non-array → error); JSON delegates to `path_get`.
2307fn apply_one_subscript(
2308    target_v: Value<'static>,
2309    index: &Expr,
2310    row: &Row<'static>,
2311    ctx: &EvalContext<'_>,
2312) -> Result<Value<'static>, EvalError> {
2313    let idx_v = eval_expr(index, row, ctx)?;
2314    if matches!(target_v, Value::Null) || matches!(idx_v, Value::Null) {
2315        return Ok(Value::Null);
2316    }
2317    // v7.38 (read01) — JSON/JSONB subscripting (`j['a']`, `j[0]`, chained
2318    // `j['a']['b']`) is object/array access, identical to the `->` operator
2319    // (text key → object field, integer → 0-based array element). PG 14+.
2320    if matches!(target_v, Value::Json(_)) {
2321        return crate::json::path_get(&target_v, &idx_v, false);
2322    }
2323    let i: i64 = match idx_v {
2324        Value::Int(n) => i64::from(n),
2325        Value::BigInt(n) => n,
2326        Value::SmallInt(n) => i64::from(n),
2327        other => {
2328            return Err(EvalError::TypeMismatch {
2329                detail: format!(
2330                    "array subscript must be integer, got {}",
2331                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
2332                ),
2333            });
2334        }
2335    };
2336    if i < 1 {
2337        return Ok(Value::Null);
2338    }
2339    let pos = (i - 1) as usize;
2340    match array_element_at(&target_v, pos) {
2341        Some(v) => Ok(v),
2342        None if array_len(&target_v).is_some() => Ok(Value::Null),
2343        None => Err(EvalError::TypeMismatch {
2344            detail: format!(
2345                "subscript target must be an array, got {}",
2346                crate::conversions::pg_type_name_for_error_opt(target_v.data_type())
2347            ),
2348        }),
2349    }
2350}
2351
2352/// v7.38 (read01, 2D-subscript) — index a 2-D array (`arr[i][j]`). PG needs
2353/// exactly two subscripts to reach an element; a single subscript on a 2-D
2354/// array yields NULL (not the row), and any out-of-range index → NULL. Both
2355/// subscripts are 1-based.
2356fn eval_matrix_subscript(
2357    base: &Value<'static>,
2358    idx_exprs: &[&Expr],
2359    row: &Row<'static>,
2360    ctx: &EvalContext<'_>,
2361) -> Result<Value<'static>, EvalError> {
2362    if idx_exprs.len() != 2 {
2363        return Ok(Value::Null);
2364    }
2365    let mut idx = [0i64; 2];
2366    for (k, ix) in idx_exprs.iter().enumerate() {
2367        idx[k] = match eval_expr(ix, row, ctx)? {
2368            Value::Null => return Ok(Value::Null),
2369            Value::Int(n) => i64::from(n),
2370            Value::BigInt(n) => n,
2371            Value::SmallInt(n) => i64::from(n),
2372            other => {
2373                return Err(EvalError::TypeMismatch {
2374                    detail: format!(
2375                        "array subscript must be integer, got {}",
2376                        crate::conversions::pg_type_name_for_error_opt(other.data_type())
2377                    ),
2378                });
2379            }
2380        };
2381    }
2382    let (r, c) = (idx[0], idx[1]);
2383    if r < 1 || c < 1 {
2384        return Ok(Value::Null);
2385    }
2386    let (ri, ci) = ((r - 1) as usize, (c - 1) as usize);
2387    macro_rules! elem {
2388        ($rows:expr, $map:expr) => {
2389            Ok($rows
2390                .get(ri)
2391                .and_then(|inner| inner.get(ci))
2392                .map_or(Value::Null, |cell| cell.as_ref().map_or(Value::Null, $map)))
2393        };
2394    }
2395    match base {
2396        Value::IntArray2D(rows) => elem!(rows, |n| Value::Int(*n)),
2397        Value::BigIntArray2D(rows) => elem!(rows, |n| Value::BigInt(*n)),
2398        Value::BoolArray2D(rows) => elem!(rows, |b| Value::Bool(*b)),
2399        Value::TextArray2D(rows) => {
2400            elem!(rows, |s| Value::Text(alloc::borrow::Cow::Owned(s.clone())))
2401        }
2402        _ => Ok(Value::Null),
2403    }
2404}
2405
2406/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
2407/// (stack-depth guard budget); body unchanged.
2408#[inline(never)]
2409fn eval_cast_arm(
2410    expr: &Expr,
2411    target: &CastTarget,
2412    row: &Row<'static>,
2413    ctx: &EvalContext<'_>,
2414) -> Result<Value<'static>, EvalError> {
2415    let v = eval_expr(expr, row, ctx)?;
2416    // v7.39 (round 473) — `<oid>::regclass` names the relation.
2417    //
2418    // The cast itself has no catalog, so it answered the bare number:
2419    // `indexrelid::regclass` printed `100001` where PG prints `ix1`, and
2420    // pg_class / pg_index rows are read by joining on oids and rendering
2421    // them — a tool cannot match the two up. `relation_name_for_oid`
2422    // mirrors `relation_oid`'s walks so the two directions agree; an oid
2423    // that names nothing keeps rendering as the number, which is what PG
2424    // does for a dropped relation's oid too.
2425    if matches!(target, CastTarget::RegClass)
2426        && let Some(cat) = ctx.catalog
2427    {
2428        let oid = match &v {
2429            Value::Int(n) => Some(i64::from(*n)),
2430            Value::BigInt(n) => Some(*n),
2431            _ => None,
2432        };
2433        if let Some(oid) = oid
2434            && let Some(name) = crate::system_catalog::relation_name_for_oid(cat, oid)
2435        {
2436            return Ok(Value::text(name));
2437        }
2438    }
2439    // v7.38 (read01 P6.40) — a cast to a user DOMAIN (`x::posint`)
2440    // enforces the domain's NOT NULL + CHECK constraints, matching PG.
2441    // The base-type coercion already happened when `v` was produced
2442    // (the domain is a constrained alias of its base type); here we
2443    // only run the constraints.
2444    if let CastTarget::Named(name) = target
2445        && let Some(cat) = ctx.catalog
2446    {
2447        if let Some(dom) = cat.domain_types().get(name.as_str()) {
2448            return apply_domain_constraints(v, dom, name, cat);
2449        }
2450        // v7.38 (read01 P6.67) — `'label'::<user enum>` validates the
2451        // label against the enum's members (a non-member errors like
2452        // PG). A typed NULL passes through carrying the enum type.
2453        if let Some(en) = cat.enum_types().get(name.as_str()) {
2454            return apply_enum_cast(v, en, name);
2455        }
2456        // v7.39 (read01 rowtypes.c) — `'(1,x)'::<composite>` parses PG's
2457        // record text form against the type's field list; a ROW value
2458        // re-labels its fields.
2459        if let Some(comp) = cat.composite_types().get(name.as_str()) {
2460            // v7.39 (round 264) — pass the catalog so a NESTED composite
2461            // field resolves into a record rather than staying text.
2462            return apply_composite_cast_in(v, comp, ctx.catalog);
2463        }
2464        // v7.39 (round 509) — every TABLE also names a row type, and
2465        // `jsonb_populate_record(NULL::mytable, …)` is PG's canonical
2466        // spelling for "shaped like this table". PG accepts `NULL::mytable`
2467        // and refuses `1::mytable` with "cannot cast type integer to
2468        // mytable" — the type exists, the conversion does not. This only
2469        // ever worked here because a NULL skipped cast resolution entirely;
2470        // now that it does not, the row type has to be named explicitly.
2471        if cat.get(name.as_str()).is_some() {
2472            return if matches!(v, Value::Null) {
2473                Ok(Value::Null)
2474            } else {
2475                Err(EvalError::TypeMismatch {
2476                    detail: alloc::format!(
2477                        "cannot cast type {} to {name}",
2478                        crate::eval::strings::pg_typeof_name(&v),
2479                    ),
2480                })
2481            };
2482        }
2483        // v7.39 (round 513) — `regnamespace` and `regrole` resolve against
2484        // things that live outside the type table: schemas on the catalog,
2485        // roles on the engine. They belong here for the same reason the
2486        // relation check above does — this is the arm that can see them.
2487        // v7.39 (round 526) — the NUMERIC direction, which is the one a
2488        // catalog join uses: `relnamespace::regnamespace` names the
2489        // schema a relation lives in, and it errored with "unsupported
2490        // cast target" while `'public'::regnamespace` worked. Round 513
2491        // added the name direction only, so the half that reads a
2492        // catalog was the half missing.
2493        if (name.eq_ignore_ascii_case("regnamespace") || name.eq_ignore_ascii_case("regrole"))
2494            && let Some(oid) = match &v {
2495                Value::Int(n) => Some(i64::from(*n)),
2496                Value::BigInt(n) => Some(*n),
2497                _ => None,
2498            }
2499        {
2500            let named = if name.eq_ignore_ascii_case("regnamespace") {
2501                crate::system_catalog::schema_name_for_oid(oid)
2502            } else {
2503                ctx.engine.and_then(|e| e.role_name_for_oid(oid))
2504            };
2505            // PG prints the bare number for an oid that names nothing,
2506            // exactly as `regclass` does.
2507            return Ok(Value::text(
2508                named.unwrap_or_else(|| alloc::format!("{oid}")),
2509            ));
2510        }
2511        if name.eq_ignore_ascii_case("regnamespace")
2512            && let Value::Text(t) = &v
2513        {
2514            let want = t.trim().trim_matches('"');
2515            return if spg_storage::is_builtin_schema(want) || cat.schema_exists(want) {
2516                Ok(Value::text(want.to_string()))
2517            } else {
2518                Err(EvalError::TypeMismatch {
2519                    detail: alloc::format!("schema \"{want}\" does not exist"),
2520                })
2521            };
2522        }
2523        if name.eq_ignore_ascii_case("regrole")
2524            && let Value::Text(t) = &v
2525        {
2526            let want = t.trim().trim_matches('"').to_string();
2527            // PG ships predefined roles that exist whether or not anybody
2528            // created them; SPG carries the rest on the engine.
2529            const PREDEFINED: &[&str] = &[
2530                "pg_read_all_data",
2531                "pg_write_all_data",
2532                "pg_monitor",
2533                "pg_read_all_settings",
2534                "pg_read_all_stats",
2535                "pg_stat_scan_tables",
2536                "pg_signal_backend",
2537                "pg_checkpoint",
2538                "pg_maintain",
2539                "pg_use_reserved_connections",
2540                "pg_create_subscription",
2541            ];
2542            let known = PREDEFINED.iter().any(|r| r.eq_ignore_ascii_case(&want))
2543                || ctx.engine.is_some_and(|e| e.role_exists(&want));
2544            return if known {
2545                Ok(Value::text(want))
2546            } else {
2547                Err(EvalError::TypeMismatch {
2548                    detail: alloc::format!("role \"{want}\" does not exist"),
2549                })
2550            };
2551        }
2552        // v7.39 (round 509) — the cast target is checked even when the
2553        // operand is NULL. `cast_value_in` short-circuits a NULL before it
2554        // looks at the target, so `NULL::nosuchtype` silently answered NULL
2555        // and `pg_typeof(NULL::nosuchtype)` answered `unknown`, while
2556        // `1::nosuchtype` errored — the gap was exactly the NULL case, in
2557        // both spellings. Everything a catalog can name has been tried by
2558        // now; what is left is the builtin table.
2559        if matches!(v, Value::Null)
2560            && !crate::eval::cast::builtin_target_resolves(name, ctx.mysql_dialect)
2561        {
2562            return Err(EvalError::TypeMismatch {
2563                detail: cast::unknown_type_error_text(name),
2564            });
2565        }
2566    }
2567    // v7.39 (round 285) — `::record`, the anonymous composite type. PG
2568    // treats it as an IDENTITY cast on anything already composite: the
2569    // value keeps its fields and their names, so `(ROW(1,2)::record).f1`
2570    // and `(r).x` still resolve. Only a non-composite is refused, with
2571    // PG's wording. `record` is not a catalog type, so this cannot live
2572    // in the lookups above.
2573    if let CastTarget::Named(name) = target
2574        && name.eq_ignore_ascii_case("record")
2575    {
2576        return match v {
2577            Value::Composite(_) | Value::Null => Ok(v),
2578            other => Err(EvalError::TypeMismatch {
2579                detail: alloc::format!(
2580                    "cannot cast type {} to record",
2581                    crate::eval::strings::pg_typeof_name(&other),
2582                ),
2583            }),
2584        };
2585    }
2586    // v7.38 (read01, T22) — a numeric OID cast to regclass reverse-looks
2587    // up the user relation name (PG's 16384+ band, assigned in
2588    // table_names() order). System OIDs / non-matches fall through to
2589    // the integer-rendering path in cast_value.
2590    if matches!(target, CastTarget::RegClass) {
2591        // v7.39 (read01 ruleutils.c) — regclass is DUAL-shape: oid for
2592        // catalog joins (conrelid = 't'::regclass), name for display.
2593        let oid_in = match &v {
2594            Value::SmallInt(n) => Some(i64::from(*n)),
2595            Value::Int(n) => Some(i64::from(*n)),
2596            Value::BigInt(n) => Some(*n),
2597            _ => None,
2598        };
2599        if let (Some(oid), Some(cat)) = (oid_in, ctx.catalog) {
2600            if oid >= 16384 {
2601                if let Some(name) = cat.table_names().into_iter().nth((oid - 16384) as usize) {
2602                    return Ok(Value::RegClass(oid, name.into()));
2603                }
2604            }
2605        }
2606        if let (Value::Text(s), Some(cat)) = (&v, ctx.catalog) {
2607            let bare = s
2608                .rsplit('.')
2609                .next()
2610                .unwrap_or(s)
2611                .trim_matches('"')
2612                .to_string();
2613            if let Some(oid) = regclass_name_to_oid(cat, &bare) {
2614                return Ok(Value::RegClass(oid, bare.into()));
2615            }
2616            // v7.39 (round 337, V62) — a name that is no relation at all is
2617            // PG's error, not a silent pass-through. `'nope'::regclass`
2618            // used to answer the TEXT `nope`, so a downstream
2619            // `pg_get_viewdef('nope'::regclass)` reported "no such view"
2620            // when the truth is there is no such relation — and a
2621            // catalog join on it quietly matched nothing. PG 18.4:
2622            // `ERROR: relation "nope" does not exist`. (`to_regclass` is
2623            // the spelling that answers NULL instead, and still does.)
2624            //
2625            // The system views SPG synthesises have no oid space, so they
2626            // keep the textual form rather than erroring.
2627            const SYSTEM_RELS: &[&str] = &[
2628                "pg_roles",
2629                "pg_user",
2630                "pg_tables",
2631                "pg_views",
2632                "pg_settings",
2633                "pg_stat_activity",
2634                "pg_stat_database",
2635                "pg_stat_user_tables",
2636                "pg_class",
2637                "pg_attribute",
2638                "pg_type",
2639                "pg_proc",
2640                "pg_namespace",
2641                "pg_constraint",
2642                "pg_index",
2643                "pg_rewrite",
2644            ];
2645            if !SYSTEM_RELS.contains(&bare.as_str()) {
2646                return Err(EvalError::TypeMismatch {
2647                    detail: alloc::format!("relation \"{bare}\" does not exist"),
2648                });
2649            }
2650        }
2651    }
2652    // v7.39 (round 339, V63) — `::regproc` / `::regprocedure` resolve
2653    // against the USER function catalog too. Name resolution ran against
2654    // the static pg_proc table alone, so `'my_fn'::regproc` — the form
2655    // every catalog query and pg_dump uses to name a function — raised
2656    // `function "my_fn" does not exist` for a function that plainly did.
2657    // The cast layer has no catalog handle; this is the same interception
2658    // point the `::regclass` block above uses.
2659    if let (CastTarget::Named(tname), Some(cat), Value::Text(s)) = (target, ctx.catalog, &v) {
2660        let lower = tname.to_ascii_lowercase();
2661        if matches!(lower.as_str(), "regproc" | "regprocedure") {
2662            let raw = s.trim();
2663            // regprocedure carries the argument list: `f(int,text)`.
2664            let (name_part, args_part) = match raw.split_once('(') {
2665                Some((n, rest)) => (n.trim(), Some(rest.trim_end_matches(')'))),
2666                None => (raw, None),
2667            };
2668            let bare = name_part
2669                .strip_prefix("public.")
2670                .unwrap_or(name_part)
2671                .trim_matches('"');
2672            let cands = cat.functions_named(bare);
2673            if let Some(args_txt) = args_part {
2674                // An overload IS distinguishable here — the argument list
2675                // is what regprocedure exists to carry.
2676                let want =
2677                    crate::system_catalog::canonical_arg_types(&alloc::format!("({args_txt})"));
2678                if let Some(f) = cands
2679                    .iter()
2680                    .find(|f| crate::system_catalog::canonical_arg_types(&f.args_repr) == want)
2681                {
2682                    let rendered = alloc::format!(
2683                        "{bare}({})",
2684                        crate::system_catalog::canonical_arg_types(&f.args_repr)
2685                    );
2686                    // v7.39 (round 342, V65) — dual shape: the oid for
2687                    // catalog joins, the rendering for display.
2688                    let oid = crate::system_catalog::function_oid_by_signature(cat, bare, &want)
2689                        .unwrap_or(0);
2690                    return Ok(Value::RegProc(oid, rendered.into()));
2691                }
2692            } else {
2693                match cands.len() {
2694                    0 => {}
2695                    1 => {
2696                        let oid = crate::system_catalog::function_oid(cat, bare).unwrap_or(0);
2697                        return Ok(Value::RegProc(oid, bare.into()));
2698                    }
2699                    _ => {
2700                        return Err(EvalError::TypeMismatch {
2701                            detail: alloc::format!("more than one function named \"{bare}\""),
2702                        });
2703                    }
2704                }
2705            }
2706        }
2707    }
2708    // v7.38 (T-tstz Phase 1) — `<timestamptz>::text` renders the offset
2709    // (`2024-01-15 10:30:00+00`); plain timestamp does not. The runtime
2710    // value is the same tz-less `Value::Timestamp`, so consult the
2711    // inner expression's static type. Falls through to the ordinary
2712    // cast on any shape the static typer can't resolve — worst case is
2713    // today's no-offset rendering, never a wrong instant.
2714    if matches!(target, CastTarget::Text)
2715        && let Value::Timestamp(t) = &v
2716        && crate::describe::describe_expr(expr, ctx.columns)
2717            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz))
2718    {
2719        // v7.39 (tz epic) — per-VALUE session offset (DST zones
2720        // vary within a statement); non-ISO DateStyles carry the
2721        // zone designation instead of the numeric offset.
2722        let off = ctx.session_tz_offset_at(*t);
2723        let abbr = ctx.session_tz_abbrev_at(*t);
2724        return Ok(Value::text(format::format_timestamptz_tz(
2725            *t,
2726            &ctx.render_style,
2727            off,
2728            abbr.as_deref(),
2729        )));
2730    }
2731    // v7.39 (read01 utils/adt, datetime.c) — the relative
2732    // reserved words resolve against the transaction clock:
2733    // 'today'/'tomorrow'/'yesterday' are midnight dates, 'now'
2734    // is the current instant ('now'::date = today). Clockless
2735    // engines fall through to the parser (which rejects them).
2736    if matches!(
2737        target,
2738        CastTarget::Date | CastTarget::Timestamp | CastTarget::Timestamptz
2739    ) && let Value::Text(word) = &v
2740        && let Some(clock) = ctx.clock
2741    {
2742        let w = word.trim().to_ascii_lowercase();
2743        if matches!(w.as_str(), "today" | "tomorrow" | "yesterday" | "now") {
2744            let now_us = clock();
2745            let today = i32::try_from(now_us.div_euclid(86_400_000_000)).ok();
2746            if let Some(today) = today {
2747                let day = match w.as_str() {
2748                    "tomorrow" => today + 1,
2749                    "yesterday" => today - 1,
2750                    _ => today,
2751                };
2752                return Ok(match (&target, w.as_str()) {
2753                    (CastTarget::Date, _) => Value::Date(day),
2754                    (_, "now") => Value::Timestamp(now_us),
2755                    _ => Value::Timestamp(crate::conversions::date_days_to_micros(day)),
2756                });
2757            }
2758        }
2759    }
2760    // v7.39 (GUC knife 5) — text INPUT to date/timestamp under a
2761    // non-MDY DateOrder disambiguates by the session order
2762    // (`'01/02/2024'::date` is Feb 1 under DMY). The default MDY
2763    // order flows through cast_value's parse_date_literal.
2764    if ctx.render_style.date_order != format::DateOrder::Mdy {
2765        match (&target, &v) {
2766            (CastTarget::Date, Value::Text(s)) => {
2767                if let Some(d) = format::parse_date_literal_ordered(s, ctx.render_style.date_order)
2768                {
2769                    return Ok(Value::Date(d));
2770                }
2771            }
2772            (CastTarget::Timestamp, Value::Text(s)) => {
2773                if let Some(t) =
2774                    format::parse_timestamp_literal_ordered(s, ctx.render_style.date_order)
2775                {
2776                    return Ok(Value::Timestamp(t));
2777                }
2778            }
2779            _ => {}
2780        }
2781    }
2782    // v7.39 (round 309, V30) — the mirror of the timestamptz arm
2783    // below. A literal carrying a zone NAME is legal input to the
2784    // zone-less types, and PG throws the zone away rather than
2785    // converting: `'2020-01-01 10:00:00 America/New_York'::timestamp`
2786    // is 10:00, not 15:00. Round 289 did this for a numeric `+02`
2787    // offset; a named zone still failed to parse at all.
2788    //
2789    // The name is not simply stripped — PG validates it, and says so
2790    // (`time zone "bogus/zone" not recognized`, lowercased) rather than
2791    // reporting a malformed literal. That check is why this belongs
2792    // here and not in `cast_value`: resolving a zone needs the host
2793    // functions, which only the context carries.
2794    let zoneless_target = match &target {
2795        CastTarget::Timestamp => Some("timestamp"),
2796        CastTarget::Date => Some("date"),
2797        // `::time` has no CastTarget of its own; it arrives named.
2798        CastTarget::Named(n) if n.eq_ignore_ascii_case("time") => Some("time"),
2799        _ => None,
2800    };
2801    if let Some(kind) = zoneless_target
2802        && let Value::Text(txt) = &v
2803        && let Some((wall, zone)) = split_trailing_zone_name(txt, ctx.render_style.date_order)
2804    {
2805        if ctx.zone_local_to_utc(zone, wall).is_none() {
2806            // Measured boundary: PG calls the token a ZONE NAME — and
2807            // so reports a misspelling as such — only when it is
2808            // path-shaped. A bare word it does not know (`ABCD`, `QQQ`,
2809            // `UTC_X`) makes the whole literal invalid syntax instead,
2810            // because nothing marks it as having meant a zone at all.
2811            if zone.contains('/') {
2812                return Err(EvalError::TypeMismatch {
2813                    detail: alloc::format!(
2814                        "time zone \"{}\" not recognized",
2815                        zone.to_ascii_lowercase()
2816                    ),
2817                });
2818            }
2819        } else {
2820            return Ok(match kind {
2821                "date" => {
2822                    Value::Date(i32::try_from(wall.div_euclid(86_400_000_000)).map_err(|_| {
2823                        EvalError::TypeMismatch {
2824                            detail: "timestamp out of DATE range".into(),
2825                        }
2826                    })?)
2827                }
2828                "time" => Value::Time(wall.rem_euclid(86_400_000_000)),
2829                _ => Value::Timestamp(wall),
2830            });
2831        }
2832    }
2833    // v7.39 (tz epic) — timestamptz INPUT: an offset-less
2834    // literal is a wall-clock reading in the session zone
2835    // (PG); a trailing IANA zone name localises there. Both
2836    // fall through to cast_value when nothing matches (its
2837    // parse treats naive input as UTC — correct for a UTC
2838    // session).
2839    if matches!(target, CastTarget::Timestamptz)
2840        && let Value::Text(txt) = &v
2841    {
2842        let order = ctx.render_style.date_order;
2843        let sess_zone = ctx
2844            .session_gucs
2845            .and_then(|g| g.get("timezone"))
2846            .map(String::as_str);
2847        // Trailing zone name: the last space-separated token,
2848        // when it names a resolvable zone (contains a letter
2849        // and isn't consumed by the plain parse).
2850        if let Some(idx) = txt.trim_end().rfind(' ') {
2851            let (head, tail) = (txt[..idx].trim(), txt[idx + 1..].trim());
2852            let tail_is_zoneish = tail.len() > 1
2853                && tail.bytes().any(|b| b.is_ascii_alphabetic())
2854                && !tail.eq_ignore_ascii_case("bc")
2855                && !tail.eq_ignore_ascii_case("ad");
2856            if tail_is_zoneish
2857                && format::parse_timestamp_literal_tz_ordered(txt, order).is_none()
2858                && let Some((wall, false)) = format::parse_timestamp_literal_tz_ordered(head, order)
2859                && let Some(utc) = ctx.zone_local_to_utc(tail, wall)
2860            {
2861                return Ok(Value::Timestamp(utc));
2862            }
2863        }
2864        if let Some((wall, had_tz)) = format::parse_timestamp_literal_tz_ordered(txt, order) {
2865            if had_tz {
2866                return Ok(Value::Timestamp(wall));
2867            }
2868            if let Some(zone) = sess_zone
2869                && !zone.eq_ignore_ascii_case("utc")
2870                && !zone.eq_ignore_ascii_case("gmt")
2871                && let Some(utc) = ctx.zone_local_to_utc(zone, wall)
2872            {
2873                return Ok(Value::Timestamp(utc));
2874            }
2875            return Ok(Value::Timestamp(wall));
2876        }
2877    }
2878    // v7.39 (round 523) — a NAIVE timestamp cast to timestamptz is a
2879    // wall-clock reading in the session zone, exactly as the text form
2880    // above already is. This was a no-op, so under `SET TimeZone =
2881    // 'Asia/Tokyo'` a `TIMESTAMP '2020-01-01 00:00:00'::timestamptz`
2882    // named 09:00 JST — a different INSTANT, nine hours from the one PG
2883    // stores, not a different rendering of the same one.
2884    //
2885    // The source's static type is the witness: SPG keeps timestamptz in
2886    // the same `Value::Timestamp`, so only an expression that is not
2887    // ALREADY timestamptz may be shifted, or a tstz-to-tstz cast would
2888    // move the instant twice.
2889    if matches!(target, CastTarget::Timestamptz)
2890        && let Value::Timestamp(wall) = &v
2891        && !matches!(
2892            crate::describe::describe_expr(expr, ctx.columns).map(|s| s.ty),
2893            Some(spg_storage::DataType::Timestamptz)
2894        )
2895        && let Some(zone) = ctx.session_gucs.and_then(|g| g.get("timezone"))
2896        && !zone.eq_ignore_ascii_case("utc")
2897        && !zone.eq_ignore_ascii_case("gmt")
2898        && let Some(utc) = ctx.zone_local_to_utc(zone, *wall)
2899    {
2900        return Ok(Value::Timestamp(utc));
2901    }
2902    // v7.39 (round 523) — and the other direction: a timestamptz cast
2903    // DOWN to a zone-free type reads the local clock in the session
2904    // zone. `(TIMESTAMPTZ '2020-01-01 15:00:00Z')::date` answered
2905    // 2020-01-01 in Tokyo where PG answers 2020-01-02 — a whole day out
2906    // for every instant in the last nine hours of a UTC day, which is
2907    // exactly the shape a daily report groups on. `now()::timestamp`
2908    // likewise disagreed with `now() AT TIME ZONE <session zone>`, which
2909    // PG defines to be the same value.
2910    if matches!(target, CastTarget::Timestamp | CastTarget::Date)
2911        && let Value::Timestamp(t) = &v
2912        && crate::describe::describe_expr(expr, ctx.columns)
2913            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz))
2914    {
2915        let local = t.saturating_add(ctx.session_tz_offset_at(*t));
2916        return Ok(match target {
2917            CastTarget::Date => i32::try_from(local.div_euclid(86_400_000_000))
2918                .map_or(Value::Timestamp(local), Value::Date),
2919            _ => Value::Timestamp(local),
2920        });
2921    }
2922    // v7.39 (GUC knife 3) — the out-function casts honour the
2923    // session render style, like PG's date_out/interval_out/
2924    // float8out under DateStyle/IntervalStyle/extra_float_digits.
2925    if matches!(target, CastTarget::Text) {
2926        match &v {
2927            // v7.39 (round 524) — `bytea_out` is a render GUC too.
2928            Value::Bytes(b) if ctx.render_style.bytea_escape => {
2929                return Ok(Value::text(format::format_bytea_escape(b)));
2930            }
2931            Value::Date(d) => {
2932                return Ok(Value::text(format::format_date_styled(
2933                    *d,
2934                    &ctx.render_style,
2935                )));
2936            }
2937            Value::Timestamp(t) => {
2938                return Ok(Value::text(format::format_timestamp_styled(
2939                    *t,
2940                    &ctx.render_style,
2941                )));
2942            }
2943            Value::Interval {
2944                months,
2945                days,
2946                micros,
2947            } => {
2948                return Ok(Value::text(format::format_interval_styled(
2949                    *months,
2950                    *days,
2951                    *micros,
2952                    &ctx.render_style,
2953                )));
2954            }
2955            Value::Float(x) => {
2956                return Ok(Value::text(format::format_float_styled(
2957                    *x,
2958                    &ctx.render_style,
2959                )));
2960            }
2961            Value::Real(x) => {
2962                return Ok(Value::text(format::format_real_styled(
2963                    *x,
2964                    &ctx.render_style,
2965                )));
2966            }
2967            _ => {}
2968        }
2969    }
2970    crate::eval::cast::cast_value_ref_in(v, target, ctx.mysql_dialect)
2971}
2972
2973/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
2974/// (stack-depth guard budget); body unchanged.
2975#[inline(never)]
2976fn eval_array_arm(
2977    items: &[Expr],
2978    row: &Row<'static>,
2979    ctx: &EvalContext<'_>,
2980) -> Result<Value<'static>, EvalError> {
2981    let mut materialised: Vec<Value<'static>> = Vec::with_capacity(items.len());
2982    for elem in items {
2983        materialised.push(eval_expr(elem, row, ctx)?);
2984    }
2985    // v7.38 (read01, T10) — a constructor whose elements are all 1-D
2986    // arrays builds a 2-D array (`ARRAY[[1,2],[3,4]]`). All rows must
2987    // share a length (PG: "multidimensional arrays must have array
2988    // expressions with matching dimensions"). Int rows promote to
2989    // bigint if any row is bigint; a text row makes the whole thing text.
2990    // v7.39 (read01 round 73) — a row of ANY array kind counts. Round 72 gave
2991    // `ARRAY[true,false]` its real `bool[]` type, and this detector only knew
2992    // Int / BigInt / Text rows — so `ARRAY[ARRAY[true,false]]` stopped being a
2993    // 2-D array at all and collapsed into a 1-D text[] of rendered rows, with
2994    // `[1][2]` failing outright. A regression THIS campaign introduced, caught by
2995    // the very sweep that was chasing its own residual. Rows that are not
2996    // int/bigint arrays render into the text 2-D form below (SPG has no bool 2-D
2997    // storage variant — a recorded residual), but they stay 2-D.
2998    let all_arrays = !materialised.is_empty()
2999        && materialised.iter().all(|v| {
3000            values::array_len(v).is_some()
3001                && !matches!(
3002                    v,
3003                    Value::TextArray2D(_)
3004                        | Value::IntArray2D(_)
3005                        | Value::BigIntArray2D(_)
3006                        | Value::BoolArray2D(_)
3007                )
3008        });
3009    if all_arrays {
3010        let row_len = values::array_len(&materialised[0]).unwrap_or(0);
3011        let same_len = materialised
3012            .iter()
3013            .all(|v| values::array_len(v) == Some(row_len));
3014        if !same_len {
3015            return Err(EvalError::TypeMismatch {
3016                detail: "multidimensional arrays must have array expressions \
3017                         with matching dimensions"
3018                    .into(),
3019            });
3020        }
3021        // v7.39 (read01 round 75) — all-BOOL rows build a real `bool[][]`. BOOL is
3022        // the one element type whose ARRAY rendering (`t`) differs from its
3023        // scalar one (`true`), so the text-backed 2-D could not be right for it:
3024        // `ARRAY[ARRAY[true,false]]::text` wants `{{t,f}}` while `[1][2]::text`
3025        // wants `false`. Every other type renders the same either way — which is
3026        // why this is the only typed 2-D SPG needs.
3027        if materialised
3028            .iter()
3029            .all(|v| matches!(v, Value::BoolArray(_)))
3030        {
3031            let rows: Vec<Vec<Option<bool>>> = materialised
3032                .into_iter()
3033                .map(|v| match v {
3034                    Value::BoolArray(r) => r,
3035                    _ => unreachable!("checked above"),
3036                })
3037                .collect();
3038            return Ok(Value::BoolArray2D(rows));
3039        }
3040        let any_text = materialised
3041            .iter()
3042            .any(|v| !matches!(v, Value::IntArray(_) | Value::BigIntArray(_)));
3043        let any_big = materialised
3044            .iter()
3045            .any(|v| matches!(v, Value::BigIntArray(_)));
3046        if any_text {
3047            let rows: Vec<Vec<Option<String>>> = materialised
3048                .into_iter()
3049                .map(|v| match v {
3050                    Value::TextArray(r) => r,
3051                    // Any other element type renders into the text 2-D form,
3052                    // element by element. SPG has no typed 2-D storage beyond
3053                    // int / bigint / text, so a bool 2-D array IS text — and the
3054                    // SCALAR rendering is the one to use: `(arr)[1][2]::text`
3055                    // must read `false`, as in PG. (`pg_typeof` reporting
3056                    // `text[]` rather than `boolean[]` is the recorded residual;
3057                    // a typed 2-D needs new storage variants.)
3058                    other => {
3059                        let n = values::array_len(&other).unwrap_or(0);
3060                        (0..n)
3061                            .map(|i| match values::array_element_at(&other, i) {
3062                                None | Some(Value::Null) => None,
3063                                Some(v) => Some(value_to_text(&v)),
3064                            })
3065                            .collect()
3066                    }
3067                })
3068                .collect();
3069            return Ok(Value::TextArray2D(rows));
3070        }
3071        if any_big {
3072            let rows: Vec<Vec<Option<i64>>> = materialised
3073                .into_iter()
3074                .map(|v| match v {
3075                    Value::BigIntArray(r) => r,
3076                    Value::IntArray(r) => r.into_iter().map(|c| c.map(i64::from)).collect(),
3077                    _ => unreachable!(),
3078                })
3079                .collect();
3080            return Ok(Value::BigIntArray2D(rows));
3081        }
3082        let rows: Vec<Vec<Option<i32>>> = materialised
3083            .into_iter()
3084            .map(|v| match v {
3085                Value::IntArray(r) => r,
3086                _ => unreachable!(),
3087            })
3088            .collect();
3089        return Ok(Value::IntArray2D(rows));
3090    }
3091    // v7.39 (read01 round 72) — a HOMOGENEOUS array of a non-numeric, non-text
3092    // type keeps that type, and is unambiguous, so it is decided BEFORE the
3093    // numeric/text unification below. Everything outside the numeric ladder and
3094    // text used to fall into that loop's `_ => has_text = true` — a silent
3095    // degradation, not a decision: `ARRAY[true, false]` came back as `text[]`.
3096    // It usually LOOKED right (array_to_string renders `t` either way), which is
3097    // exactly what let it sit; the array FUNCTIONS are what tripped over it.
3098    if let Some(v) = values::homogeneous_typed_array(&materialised) {
3099        return Ok(crate::describe::upgrade_timestamptz_array(
3100            v,
3101            items,
3102            ctx.columns,
3103        ));
3104    }
3105    // v7.39 (round 236) — PG resolves an ARRAY constructor's elements to ONE
3106    // element type and refuses the constructor when they have no common one:
3107    // `ARRAY[1, 'a'::text]` is "ARRAY types integer and text cannot be
3108    // matched". SPG degraded to `text[]` instead, so `ARRAY[1, true]` came
3109    // back as `{1,t}` — a column of rendered strings that then behaved like
3110    // text everywhere downstream. Same rule (and the same untyped-literal
3111    // subtlety) as the set-operation resolution in round 233: a bare string
3112    // literal is PG's `unknown` and takes the other elements' type, so it is
3113    // identified from the SYNTAX, not from the value's runtime type.
3114    unify_array_elements(items, &mut materialised)?;
3115    // Coercing the untyped elements can make the array homogeneous
3116    // (`ARRAY[true,'t']` becomes two booleans), so re-try the typed-array
3117    // path before falling into the numeric/text ladder below — otherwise
3118    // the now-uniform boolean array would still degrade to text[].
3119    if let Some(v) = values::homogeneous_typed_array(&materialised) {
3120        return Ok(crate::describe::upgrade_timestamptz_array(
3121            v,
3122            items,
3123            ctx.columns,
3124        ));
3125    }
3126    let mut has_text = false;
3127    let mut has_float = false;
3128    let mut has_numeric = false;
3129    let mut has_bigint = false;
3130    let mut has_int = false;
3131    // A NumericBig or non-finite (NaN/Inf) numeric can't be held in
3132    // NumericArray's `(i128, scale)` cells, so it forces the text[]
3133    // fallback rather than a lossy/panicking conversion.
3134    let mut numeric_representable = true;
3135    for v in &materialised {
3136        match v {
3137            Value::Null => {}
3138            Value::Int(_) | Value::SmallInt(_) => has_int = true,
3139            Value::BigInt(_) => has_bigint = true,
3140            Value::Numeric {
3141                kind: spg_storage::NumericKind::Finite,
3142                ..
3143            } => {
3144                has_numeric = true;
3145            }
3146            Value::Numeric { .. } => {
3147                has_numeric = true;
3148                numeric_representable = false;
3149            }
3150            Value::NumericBig(_) => {
3151                has_numeric = true;
3152                numeric_representable = false;
3153            }
3154            Value::Float(_) => has_float = true,
3155            Value::Text(_) | Value::Json(_) => has_text = true,
3156            // v7.39 (round 652) — a reg value belongs to the array by
3157            // its OID half. Falling into the catch-all made
3158            // `ARRAY['pg_class'::regclass]` a text array, so
3159            // `oid = ANY(…)` compared bigint against text and was
3160            // refused — while the identical `oid = 'pg_class'::regclass`
3161            // worked. Same defect the IN-list gate had, one layer down.
3162            Value::RegClass(..) | Value::RegProc(..) | Value::RegType(..) => has_bigint = true,
3163            _ => has_text = true,
3164        }
3165    }
3166    let any_numlike = has_int || has_bigint || has_numeric || has_float;
3167    if has_text || !any_numlike || (has_numeric && !numeric_representable) {
3168        let out: Vec<Option<String>> = materialised
3169            .into_iter()
3170            .map(|v| match v {
3171                Value::Null => None,
3172                Value::Text(s) | Value::Json(s) => Some(s.into_owned()),
3173                other => Some(value_to_text_for_array(&other, &ctx.render_style)),
3174            })
3175            .collect();
3176        return Ok(Value::TextArray(out));
3177    }
3178    // v7.38 (read01) — PG array-element unification across the numeric
3179    // ladder: any float → double precision[]; else any numeric →
3180    // numeric[] (each element keeps its own scale, PG's behaviour);
3181    // else the integer widths. Matches `pg_typeof(ARRAY[1, 2.5])` =
3182    // numeric[] and keeps downstream `[i]` arithmetic numeric.
3183    if has_float {
3184        let out: Vec<Option<f64>> = materialised
3185            .into_iter()
3186            .map(|v| match v {
3187                Value::Null => None,
3188                Value::Float(f) => Some(f),
3189                Value::Int(n) => Some(f64::from(n)),
3190                Value::SmallInt(n) => Some(f64::from(n)),
3191                #[allow(clippy::cast_precision_loss)]
3192                Value::BigInt(n) => Some(n as f64),
3193                #[allow(clippy::cast_precision_loss)]
3194                Value::Numeric { scaled, scale, .. } => {
3195                    Some(scaled as f64 / libm::pow(10.0, f64::from(scale)))
3196                }
3197                _ => None,
3198            })
3199            .collect();
3200        return Ok(Value::FloatArray(out));
3201    }
3202    if has_numeric {
3203        let out: Vec<Option<(i128, u16)>> = materialised
3204            .into_iter()
3205            .map(|v| match v {
3206                Value::Null => None,
3207                Value::SmallInt(n) => Some((i128::from(n), 0)),
3208                Value::Int(n) => Some((i128::from(n), 0)),
3209                Value::BigInt(n) => Some((i128::from(n), 0)),
3210                Value::Numeric { scaled, scale, .. } => Some((scaled, scale)),
3211                _ => None,
3212            })
3213            .collect();
3214        return Ok(Value::NumericArray(out));
3215    }
3216    if has_bigint {
3217        let out: Vec<Option<i64>> = materialised
3218            .into_iter()
3219            .map(|v| match v {
3220                Value::Null => None,
3221                Value::Int(n) => Some(i64::from(n)),
3222                Value::SmallInt(n) => Some(i64::from(n)),
3223                Value::BigInt(n) => Some(n),
3224                // Keep in step with the `has_bigint` classification above:
3225                // whatever is counted there has to be convertible here, and
3226                // the arm below panics rather than errors. Round 652 added
3227                // the reg family to the classifier and this materialiser
3228                // took a wire-visible panic until it learned them too.
3229                Value::RegClass(oid, _) | Value::RegProc(oid, _) | Value::RegType(oid, _) => {
3230                    Some(oid)
3231                }
3232                _ => unreachable!(),
3233            })
3234            .collect();
3235        return Ok(Value::BigIntArray(out));
3236    }
3237    let out: Vec<Option<i32>> = materialised
3238        .into_iter()
3239        .map(|v| match v {
3240            Value::Null => None,
3241            Value::Int(n) => Some(n),
3242            Value::SmallInt(n) => Some(i32::from(n)),
3243            _ => unreachable!(),
3244        })
3245        .collect();
3246    Ok(Value::IntArray(out))
3247}
3248
3249/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
3250/// (stack-depth guard budget); body unchanged.
3251#[inline(never)]
3252fn eval_function_call_arm(
3253    name: &str,
3254    args: &[Expr],
3255    row: &Row<'static>,
3256    ctx: &EvalContext<'_>,
3257) -> Result<Value<'static>, EvalError> {
3258    // v7.39 (read01 round 77) — named arguments (`f(x := 1)` / `f(x => 1)`).
3259    // The parser leaves them in the tree because only the catalog knows a user
3260    // function's parameter names; here they become positional, once, for
3261    // builtins and user functions alike.
3262    // v7.39 (read01 round 100) — `VARIADIC <array>` splices the array's
3263    // elements in as individual trailing arguments before dispatch, so a
3264    // variadic builtin (concat / concat_ws / format / …) sees them exactly as
3265    // if they had been written out. Done before the named-arg pass and the
3266    // positional dispatch.
3267    if args.iter().any(|a| matches!(a, Expr::Variadic(_))) {
3268        let expanded = expand_variadic_args(args, row, ctx)?;
3269        return eval_function_call_arm(name, &expanded, row, ctx);
3270    }
3271    // v7.39 (round 276) — `date_part('timezone'|…, <timestamp>)` must be
3272    // REJECTED the way EXTRACT already rejects it, and the judgement
3273    // needs the argument's STATIC declared type: SPG stores timestamptz
3274    // in the same `Value::Timestamp`, so a timestamptz legitimately
3275    // answers 0 while a plain timestamp must error. The dispatch below
3276    // receives values, not expressions, so the check belongs here —
3277    // the same place, and the same r237 trust rule (only a cast or a
3278    // column is believed), that the EXTRACT arm uses.
3279    if name.eq_ignore_ascii_case("date_part")
3280        && args.len() == 2
3281        && let Expr::Literal(spg_sql::ast::Literal::String(unit)) = &args[0]
3282        && matches!(
3283            unit.to_ascii_lowercase().as_str(),
3284            "timezone" | "timezone_hour" | "timezone_minute"
3285        )
3286        && matches!(&args[1], Expr::Cast { .. } | Expr::Column(_))
3287        && let Some(sch) = crate::describe::describe_expr(&args[1], ctx.columns)
3288        && matches!(sch.ty, spg_storage::DataType::Timestamp)
3289    {
3290        return Err(EvalError::TypeMismatch {
3291            detail: alloc::format!(
3292                "unit \"{}\" not supported for type timestamp without time zone",
3293                unit.to_ascii_lowercase()
3294            ),
3295        });
3296    }
3297    // v7.39 (round 258) — `pg_typeof` over an ENUM. An enum value travels
3298    // as `Value::Text` (its label), so the value-driven namer answered
3299    // `text`; the type lives in the EXPRESSION, which this arm still has.
3300    // `expr_enum_type_name` resolves a column or a cast statically — the
3301    // same static-only discipline round 253 used for EXTRACT's type name.
3302    if name.eq_ignore_ascii_case("pg_typeof")
3303        && let [arg] = args
3304    {
3305        // The name must be a REAL enum in the catalog. `expr_enum_type_name`
3306        // returns any named cast's target verbatim — it is only a
3307        // pre-filter for `expr_enum_labels`, which does the catalog
3308        // lookup — so using it alone hijacked every `x::float8` /
3309        // `x::int2` and reported SPG's internal spelling instead of PG's.
3310        // v7.39 (round 259) — domains report their own name too; like an
3311        // enum, a domain value travels as its BASE type's value, so the
3312        // name has to come from the expression. Both lookups are gated on
3313        // the catalog: `expr_enum_type_name` returns ANY named cast's
3314        // target verbatim, so an ungated use hijacks `x::float8`.
3315        let is_user_type = |e: &Expr| {
3316            expr_enum_type_name(e, ctx.columns)
3317                .filter(|n| {
3318                    // v7.39 (round 330, V48) — the information_schema
3319                    // domains are built into the server rather than
3320                    // catalog objects (a catalog domain is user data and
3321                    // would be dumped), so they are recognised here too.
3322                    crate::system_catalog::is_information_schema_domain(n)
3323                        || ctx.catalog.is_some_and(|cat| {
3324                            cat.enum_types().contains_key(*n)
3325                                || cat.domain_types().contains_key(*n)
3326                                // v7.39 (round 263) — composites too: a cast
3327                                // to one reported the generic `record`.
3328                                || cat.composite_types().contains_key(*n)
3329                        })
3330                })
3331                .map(alloc::string::String::from)
3332        };
3333        let is_enum = is_user_type;
3334        if let Some(en) = is_enum(arg) {
3335            return Ok(Value::text(en));
3336        }
3337        // `ARRAY[<enum>, …]` reports the array form.
3338        if let Expr::Array(items) = arg
3339            && let Some(first) = items.first()
3340            && let Some(en) = is_enum(first)
3341        {
3342            return Ok(Value::text(alloc::format!("{en}[]")));
3343        }
3344    }
3345    if args.iter().any(|a| matches!(a, Expr::NamedArg { .. })) {
3346        let positional = resolve_named_args(name, args, ctx)?;
3347        return eval_function_call_arm(name, &positional, row, ctx);
3348    }
3349    eval_function_call_positional(name, args, row, ctx)
3350}
3351
3352/// v7.39 (read01 round 100) — rewrite a call's argument list, replacing each
3353/// `VARIADIC <array>` with the array's elements as literal arguments. A NULL
3354/// array contributes no elements (PG treats `VARIADIC NULL` as empty). Regular
3355/// arguments are carried through untouched so they still evaluate against the
3356/// row in the recursive call.
3357fn expand_variadic_args(
3358    args: &[Expr],
3359    row: &Row<'static>,
3360    ctx: &EvalContext<'_>,
3361) -> Result<alloc::vec::Vec<Expr>, EvalError> {
3362    let mut out = alloc::vec::Vec::with_capacity(args.len());
3363    for a in args {
3364        if let Expr::Variadic(inner) = a {
3365            let v = eval_expr(inner, row, ctx)?;
3366            let elems = crate::select::array_value_to_elements(&v).map_err(|_| {
3367                EvalError::TypeMismatch {
3368                    detail: "VARIADIC argument must be an array".into(),
3369                }
3370            })?;
3371            for e in elems {
3372                out.push(Expr::Literal(crate::value_to_literal(e)));
3373            }
3374        } else {
3375            out.push(a.clone());
3376        }
3377    }
3378    Ok(out)
3379}
3380
3381/// The declared parameter names of `fname`, or `None` when it takes none.
3382/// Builtins whose parameters PG names live in the table; everything else asks
3383/// the catalog, where a user function's `args_repr` has carried its parameter
3384/// names since the day CREATE FUNCTION stored them.
3385fn declared_param_names(fname: &str, ctx: &EvalContext<'_>) -> Option<alloc::vec::Vec<String>> {
3386    let lower = fname.to_ascii_lowercase();
3387    let builtin: &[&str] = match lower.as_str() {
3388        "make_date" => &["year", "month", "day"],
3389        "make_time" => &["hour", "min", "sec"],
3390        "make_timestamp" | "make_timestamptz" => &["year", "month", "mday", "hour", "min", "sec"],
3391        "make_interval" => &["years", "months", "weeks", "days", "hours", "mins", "secs"],
3392        _ => &[],
3393    };
3394    if !builtin.is_empty() {
3395        return Some(builtin.iter().map(|s| (*s).to_string()).collect());
3396    }
3397    let cat = ctx.catalog?;
3398    let def = cat
3399        .functions()
3400        .values()
3401        .find(|f| f.name.eq_ignore_ascii_case(&lower))?;
3402    let names = spg_storage::function_arg_names(&def.args_repr);
3403    if names.iter().all(alloc::string::String::is_empty) {
3404        return None;
3405    }
3406    Some(names)
3407}
3408
3409/// Rewrite a call's arguments into positional order. Positional arguments fill
3410/// slots left to right; a named one goes to its declared slot. Slots nobody
3411/// filled stay absent for a user function (arity is checked at the call) and
3412/// become integer 0 for the `make_*` builtins, whose trailing fields PG
3413/// defaults that way.
3414fn resolve_named_args(
3415    fname: &str,
3416    args: &[Expr],
3417    ctx: &EvalContext<'_>,
3418) -> Result<alloc::vec::Vec<Expr>, EvalError> {
3419    let Some(params) = declared_param_names(fname, ctx) else {
3420        return Err(EvalError::TypeMismatch {
3421            detail: alloc::format!("function {fname}(...) does not support named arguments"),
3422        });
3423    };
3424    let mut slots: alloc::vec::Vec<Option<Expr>> = (0..params.len()).map(|_| None).collect();
3425    let mut next_positional = 0usize;
3426    for a in args {
3427        let (idx, val) = match a {
3428            Expr::NamedArg { name, expr } => {
3429                let i = params
3430                    .iter()
3431                    .position(|p| p.eq_ignore_ascii_case(name))
3432                    .ok_or_else(|| EvalError::TypeMismatch {
3433                        detail: alloc::format!("{fname}(...) has no argument named \"{name}\""),
3434                    })?;
3435                (i, (**expr).clone())
3436            }
3437            other => {
3438                let i = next_positional;
3439                next_positional += 1;
3440                (i, other.clone())
3441            }
3442        };
3443        if idx >= slots.len() {
3444            return Err(EvalError::TypeMismatch {
3445                detail: alloc::format!("{fname}(...) got too many arguments"),
3446            });
3447        }
3448        if slots[idx].is_some() {
3449            return Err(EvalError::TypeMismatch {
3450                detail: alloc::format!("{fname}(...) got multiple values for one argument"),
3451            });
3452        }
3453        slots[idx] = Some(val);
3454    }
3455    let make_family = fname.to_ascii_lowercase().starts_with("make_");
3456    let mut out = alloc::vec::Vec::with_capacity(slots.len());
3457    for slot in slots {
3458        match slot {
3459            Some(e) => out.push(e),
3460            None if make_family => {
3461                out.push(Expr::Literal(spg_sql::ast::Literal::Integer(0)));
3462            }
3463            // A user function's unfilled slot is simply not passed; the call's
3464            // own arity check phrases the error.
3465            None => {}
3466        }
3467    }
3468    Ok(out)
3469}
3470
3471fn eval_function_call_positional(
3472    name: &str,
3473    args: &[Expr],
3474    row: &Row<'static>,
3475    ctx: &EvalContext<'_>,
3476) -> Result<Value<'static>, EvalError> {
3477    // v7.39 (round 237) — COALESCE / GREATEST / LEAST resolve their
3478    // arguments to one type the way CASE and ARRAY do. Checked statically:
3479    // an argument may have side effects (`COALESCE(nextval('s'), 1)`), so
3480    // its declared type is read rather than its value.
3481    if matches!(args.len(), 2..) {
3482        let construct = if name.eq_ignore_ascii_case("coalesce") {
3483            Some("COALESCE")
3484        } else if name.eq_ignore_ascii_case("greatest") {
3485            Some("GREATEST")
3486        } else if name.eq_ignore_ascii_case("least") {
3487            Some("LEAST")
3488        } else {
3489            None
3490        };
3491        if let Some(construct) = construct {
3492            unify_branch_types_static(construct, args.iter(), ctx)?;
3493        }
3494    }
3495    // v7.39 (read01 utils/adt, enum.c) — the enum introspection
3496    // v7.39 (read01 utils/adt, enum.c) — the enum introspection
3497    // family needs the ARGUMENT'S STATIC TYPE (the value is
3498    // usually NULL::enumtype): first/last/range over the
3499    // catalog's member order. Out-of-line so eval_expr's
3500    // recursion frame stays small (stack-depth guard budget).
3501    if (name.eq_ignore_ascii_case("enum_first")
3502        || name.eq_ignore_ascii_case("enum_last")
3503        || name.eq_ignore_ascii_case("enum_range"))
3504        && enum_introspection_applies(args, ctx)
3505    {
3506        return eval_enum_introspection(name, args, row, ctx);
3507    }
3508    // v7.39 (tz epic) — AT TIME ZONE (fn form: timezone(zone, ts))
3509    // with a NAMED zone needs the host tzdb and the argument's
3510    // static type for its two directions:
3511    //   naive AT ZONE  -> that zone's wall clock -> UTC instant
3512    //   tstz  AT ZONE  -> UTC instant -> that zone's wall clock
3513    // Fixed offsets / abbreviations keep the legacy path below.
3514    // v7.39 (round 523) — `to_char(tstz, fmt)` renders the LOCAL clock
3515    // in the session zone, and its zone tokens name that zone. It was
3516    // rendering the UTC reading and spelling it `UTC`, so a formatted
3517    // stamp disagreed with the same value's own `::text`.
3518    if args.len() == 2
3519        && name.eq_ignore_ascii_case("to_char")
3520        && let Some(zone) = ctx.session_gucs.and_then(|g| g.get("timezone"))
3521        && !zone.eq_ignore_ascii_case("utc")
3522        && !zone.eq_ignore_ascii_case("gmt")
3523        && crate::describe::describe_expr(&args[0], ctx.columns)
3524            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz))
3525        && let Value::Timestamp(t) = eval_expr(&args[0], row, ctx)?
3526    {
3527        let off = ctx.session_tz_offset_at(t);
3528        let abbrev = ctx
3529            .session_tz_abbrev_at(t)
3530            .unwrap_or_else(|| zone.to_uppercase());
3531        let vals = [
3532            Value::Timestamp(t.saturating_add(off)),
3533            eval_expr(&args[1], row, ctx)?,
3534        ];
3535        return crate::eval::strings::to_char_in_zone(&vals, Some((&abbrev, off)));
3536    }
3537    // v7.39 (round 523) — `date_trunc(unit, tstz)` truncates on the
3538    // LOCAL calendar in the session zone. It was truncating in UTC and
3539    // rendering the result in the session zone, so under `SET TimeZone =
3540    // 'Asia/Tokyo'` a day truncation answered `2020-01-01 09:00:00+09` —
3541    // not a day boundary at all, and the wrong day for anything before
3542    // 09:00. Every report grouped by day was cut nine hours late.
3543    //
3544    // The three-argument form already does exactly this, DST reverse
3545    // lookup and all, so the session zone is passed to THAT rather than
3546    // written a second time. Only a statically-known timestamptz shifts:
3547    // a naive timestamp has no zone to be read in.
3548    if args.len() == 2
3549        && (name.eq_ignore_ascii_case("date_trunc") || name.eq_ignore_ascii_case("date_bin"))
3550        && let Some(zone) = ctx.session_gucs.and_then(|g| g.get("timezone"))
3551        && !zone.eq_ignore_ascii_case("utc")
3552        && !zone.eq_ignore_ascii_case("gmt")
3553        && crate::describe::describe_expr(&args[1], ctx.columns)
3554            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz))
3555    {
3556        let vals = [
3557            eval_expr(&args[0], row, ctx)?,
3558            eval_expr(&args[1], row, ctx)?,
3559            Value::text(zone.clone()),
3560        ];
3561        return datetime::date_trunc(&vals, ctx);
3562    }
3563    if args.len() == 2
3564        && name.eq_ignore_ascii_case("timezone")
3565        && let zone_v = eval_expr(&args[0], row, ctx)?
3566        && let Value::Text(zone) = &zone_v
3567        && datetime::resolve_zone_offset(zone.as_ref()).is_none()
3568        && !zone.trim().eq_ignore_ascii_case("utc")
3569        && !zone.trim().eq_ignore_ascii_case("gmt")
3570        && zone.parse::<i64>().is_err()
3571        && ctx.tz_offset_fn.is_some()
3572    {
3573        let zone = zone.trim();
3574        let src_is_tstz = crate::describe::describe_expr(&args[1], ctx.columns)
3575            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz));
3576        let inner = eval_expr(&args[1], row, ctx)?;
3577        if let Value::Timestamp(t) = inner {
3578            if src_is_tstz {
3579                if let Some(off) = ctx.zone_offset_at(zone, t) {
3580                    return Ok(Value::Timestamp(t + off));
3581                }
3582            } else if let Some(utc) = ctx.zone_local_to_utc(zone, t) {
3583                return Ok(Value::Timestamp(utc));
3584            }
3585            return Err(EvalError::TypeMismatch {
3586                detail: alloc::format!("time zone \"{zone}\" not recognized"),
3587            });
3588        }
3589    }
3590    // v7.29 (round-22 phase 3) - prefix fast path: LEFT(col, n)
3591    // on a TEXT column borrows the cell and clones only the
3592    // prefix. The generic path clones the WHOLE cell first -
3593    // a LEFT(body, 120) over 24k x 30 KB rows spent 383 ms
3594    // copying bytes it then threw away (7 ms without LEFT).
3595    if args.len() == 2
3596        && name.eq_ignore_ascii_case("left")
3597        && let Expr::Column(c) = &args[0]
3598        && let Some(cell) = resolve_column_borrowed(c, row, ctx)?
3599    {
3600        {
3601            match cell {
3602                Value::Null => return Ok(Value::Null),
3603                Value::Text(t) => {
3604                    let n_v = eval_expr(&args[1], row, ctx)?;
3605                    if let Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) = n_v {
3606                        let n = match n_v {
3607                            Value::SmallInt(x) => i64::from(x),
3608                            Value::Int(x) => i64::from(x),
3609                            Value::BigInt(x) => x,
3610                            _ => 0,
3611                        };
3612                        return Ok(Value::text(text_prefix_chars(t, n)));
3613                    }
3614                }
3615                _ => {}
3616            }
3617        }
3618    }
3619    // v7.38 (T-tstz Phase 1) — the ONE case where pg_typeof needs the
3620    // static type: timestamptz. The runtime value is a tz-less
3621    // Value::Timestamp, so the value-driven answer below can only ever
3622    // say "without time zone". For every other type the value-driven
3623    // path is strictly better (it distinguishes json vs jsonb, keeps
3624    // NULL as "unknown", and is not fooled by describe_expr's lossy
3625    // heuristics), so we consult the static typer ONLY when it says
3626    // Timestamptz and otherwise fall through untouched.
3627    if args.len() == 1
3628        && name.eq_ignore_ascii_case("pg_typeof")
3629        && crate::describe::describe_expr(&args[0], ctx.columns)
3630            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz))
3631    {
3632        return Ok(Value::text::<alloc::string::String>(
3633            "timestamp with time zone".into(),
3634        ));
3635    }
3636    // v7.39 (round 694) — `oid[]`. Its VALUE is a BigIntArray, so the
3637    // value-driven namer answers `bigint[]`; the declared type lives in the
3638    // expression, exactly as it does for the Timestamptz arm above and for
3639    // the enum / domain / composite arms further down. The scalar `oid`
3640    // needed the same treatment in round 667.
3641    if args.len() == 1
3642        && name.eq_ignore_ascii_case("pg_typeof")
3643        && crate::describe::describe_expr(&args[0], ctx.columns)
3644            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::OidArray))
3645    {
3646        return Ok(Value::text::<alloc::string::String>("oid[]".into()));
3647    }
3648    // v7.39 (read01 round 56) — a COMPOSITE column reports its type NAME, not
3649    // the generic `record` the runtime value would give. Composite-ness lives
3650    // outside the DataType lattice (the stored form is JSON), so the witness is
3651    // the column's `user_composite_type` — the same shape as the enum witness.
3652    if args.len() == 1
3653        && name.eq_ignore_ascii_case("pg_typeof")
3654        && let Expr::Column(c) = &args[0]
3655        && let Some(cname) = ctx
3656            .columns
3657            .iter()
3658            .find(|sc| sc.name.eq_ignore_ascii_case(&c.name))
3659            .and_then(|sc| sc.user_composite_type.as_deref())
3660    {
3661        return Ok(Value::text::<alloc::string::String>(cname.into()));
3662    }
3663    // v7.39 (round 291) — `name` is a DECLARED type over a Value::Text,
3664    // so the value can never witness it; the schema is the only witness,
3665    // exactly as for a composite column above.
3666    if args.len() == 1
3667        && name.eq_ignore_ascii_case("pg_typeof")
3668        && let Expr::Column(c) = &args[0]
3669        && ctx
3670            .columns
3671            .iter()
3672            .any(|sc| sc.name.eq_ignore_ascii_case(&c.name) && sc.ty == spg_storage::DataType::Name)
3673    {
3674        return Ok(Value::text::<alloc::string::String>("name".into()));
3675    }
3676    // …and the same for a bare `'abc'::name`, where the cast TARGET is
3677    // the witness. PG computes pg_typeof statically; SPG reads the
3678    // value, which by then is an ordinary text.
3679    if args.len() == 1
3680        && name.eq_ignore_ascii_case("pg_typeof")
3681        && let Expr::Cast {
3682            target: spg_sql::ast::CastTarget::Named(n),
3683            ..
3684        } = &args[0]
3685        && n.eq_ignore_ascii_case("name")
3686    {
3687        return Ok(Value::text::<alloc::string::String>("name".into()));
3688    }
3689    // v7.39 (read01 round 116) — a bare, uncoerced string literal is PG's
3690    // `unknown` type, not text: `pg_typeof('x')` / `pg_typeof('123')` /
3691    // `pg_typeof('2024-01-01')` all report `unknown`. The literal only becomes
3692    // text once context coerces it — a cast (`'x'::text`), a concatenation, or
3693    // a function argument — each of which is a different Expr node that falls
3694    // through to the value-driven path below (which correctly says text).
3695    if args.len() == 1
3696        && name.eq_ignore_ascii_case("pg_typeof")
3697        && matches!(&args[0], Expr::Literal(spg_sql::ast::Literal::String(_)))
3698    {
3699        return Ok(Value::text::<alloc::string::String>("unknown".into()));
3700    }
3701    // v7.37.16 — pg_typeof of a NULL cell reports the COLUMN's
3702    // static type when it has one (PG: `VALUES (NULL),(1.5)`
3703    // types the column numeric and its NULL row's pg_typeof says
3704    // numeric, not unknown).
3705    //
3706    // A BARE `NULL` stays "unknown", as PG has it. That used to fall
3707    // out of TEXT being absent from the name table — a NULL literal
3708    // describes as TEXT, so the lookup returned None and the caller
3709    // reported unknown. Round 871 filled that table in, which silently
3710    // took the bare-NULL behaviour with it and broke
3711    // `pg_typeof_null_returns_unknown`. The rule is now stated rather
3712    // than emergent: an untyped NULL literal is unknown, a NULL that
3713    // was cast reports what it was cast to.
3714    if args.len() == 1 && name.eq_ignore_ascii_case("pg_typeof") {
3715        if matches!(&args[0], Expr::Literal(spg_sql::ast::Literal::Null)) {
3716            return Ok(Value::text::<alloc::string::String>("unknown".into()));
3717        }
3718        let v = eval_expr(&args[0], row, ctx)?;
3719        if matches!(v, Value::Null)
3720            && let Some(shape) = crate::describe::describe_expr(&args[0], ctx.columns)
3721            && let Some(n) = pg_typeof_name_for_datatype(shape.ty)
3722        {
3723            return Ok(Value::text(n));
3724        }
3725        // v7.39 (round 640) — a NON-null cell normally answers from the
3726        // value, which is right for every type whose identity the value
3727        // carries. `xid8` has no value of its own — a cell is a
3728        // `Value::BigInt` — so it can only ever say "bigint" unless the
3729        // schema is asked. `xid` is listed with it because a cell that
3730        // reached here as a plain integer (a synthesised catalog row
3731        // that has not been converted) should still name its column's
3732        // type rather than the storage it arrived in.
3733        //
3734        // v7.39 (round 667) — `oid` joins them for the same reason and no
3735        // other: its cell is a `Value::BigInt` too, so `pg_typeof(1::oid)`
3736        // answered `bigint`. Still not a general switch — where the value
3737        // knows its own identity it is the better witness, because an
3738        // expression's static shape is an approximation and its result is
3739        // the fact.
3740        if !matches!(v, Value::Null)
3741            && let Some(shape) = crate::describe::describe_expr(&args[0], ctx.columns)
3742            && matches!(
3743                shape.ty,
3744                spg_storage::DataType::Xid
3745                    | spg_storage::DataType::Xid8
3746                    | spg_storage::DataType::Oid
3747            )
3748            && let Some(n) = pg_typeof_name_for_datatype(shape.ty)
3749        {
3750            return Ok(Value::text(n));
3751        }
3752        return apply_function(name, &[v], ctx);
3753    }
3754    // v7.37 D.1 — COALESCE result-type coercion. PG gives COALESCE the
3755    // common type of its branches, so a typed sibling (`NULL::time`,
3756    // `col::time`) makes the whole expression that type and an untyped
3757    // string-literal branch is coerced to it. Without this,
3758    // `COALESCE(NULL::time, '12:00')::text` rendered the raw `12:00`
3759    // instead of `12:00:00`. Only kicks in when the picked value is a
3760    // bare Text and a non-text cast-target sibling exists.
3761    if name.eq_ignore_ascii_case("coalesce") && !args.is_empty() {
3762        // v7.39 (round 609) — PG's COALESCE does not evaluate a branch past
3763        // the first non-NULL one. This evaluated every branch into a `Vec`
3764        // and so RAISED errors PG never raises: `coalesce(1, 1/0)`,
3765        // `coalesce(NULL, 2, 1/0)` and `coalesce(1, NULL, 1/0)` all failed
3766        // with "division by zero" where PG answers 1, 2 and 1.
3767        //
3768        // A branch after the pick is still READ for its type — that is what
3769        // decides the result's, and `COALESCE(1, 2.5)` is numeric in both
3770        // engines — but its error is discarded, because PG never runs it and
3771        // so never reports it. A branch that fails contributes no type,
3772        // which is the same as SPG having no declared type to widen to.
3773        //
3774        // The two `Vec`s this replaces cost two allocations a row even for
3775        // `coalesce(id, 0)` over a plain INTEGER column, where the answer
3776        // needs none.
3777        let mut result: Option<Value<'static>> = None;
3778        let mut tbuf = [spg_storage::DataType::Int; 8];
3779        let mut ntypes = 0usize;
3780        let mut spill: Vec<spg_storage::DataType> = Vec::new();
3781        for a in args {
3782            let v = if result.is_none() {
3783                eval_expr(a, row, ctx)?
3784            } else {
3785                match eval_expr(a, row, ctx) {
3786                    Ok(v) => v,
3787                    Err(_) => continue,
3788                }
3789            };
3790            // v7.39 (round 649) — a NULL branch still has a TYPE, and PG
3791            // resolves COALESCE's result from the branches' declared
3792            // types, not from the values that survive. `Value::Null` has
3793            // no `data_type()`, so `coalesce(1::int, NULL::float8)`
3794            // collected only `integer` and answered integer where PG
3795            // answers double precision. Ask the expression when the
3796            // value cannot say — inside the arm that already runs, and
3797            // only on the NULL that would otherwise contribute nothing.
3798            let branch_ty = match v.data_type() {
3799                Some(t) => Some(t),
3800                None => crate::describe::describe_expr(a, ctx.columns).map(|sh| sh.ty),
3801            };
3802            if let Some(t) = branch_ty {
3803                if ntypes < tbuf.len() {
3804                    tbuf[ntypes] = t;
3805                    ntypes += 1;
3806                } else {
3807                    spill.push(t);
3808                }
3809            }
3810            if result.is_none() && !matches!(v, Value::Null) {
3811                result = Some(v);
3812            }
3813        }
3814        let result = result.unwrap_or(Value::Null);
3815        if matches!(result, Value::Text(_)) {
3816            if let Some(target) = args.iter().find_map(coalesce_type_hint) {
3817                return crate::eval::cast::cast_value(result, target);
3818            }
3819        }
3820        // v7.38 (read01) — otherwise widen the picked value to the PG
3821        // common type of all branches (COALESCE(1, 2.5) → numeric).
3822        if spill.is_empty() {
3823            return Ok(widen_to_common(result, &tbuf[..ntypes]));
3824        }
3825        let mut types: Vec<spg_storage::DataType> = tbuf[..ntypes].to_vec();
3826        types.append(&mut spill);
3827        return Ok(widen_to_common(result, &types));
3828    }
3829    let evaluated: Result<Vec<Value<'static>>, _> =
3830        args.iter().map(|a| eval_expr(a, row, ctx)).collect();
3831    let evaluated = evaluated?;
3832    // v7.39 (read01 json.c) — to_json(timestamptz) spells the instant in
3833    // ISO 8601 WITH the session-zone offset ("2024-03-09T14:05:06+00:00"),
3834    // unlike plain timestamp. The runtime value carries no tz tag, so the
3835    // argument's static type is the witness.
3836    if (name.eq_ignore_ascii_case("to_json") || name.eq_ignore_ascii_case("to_jsonb"))
3837        && evaluated.len() == 1
3838        && let Some(Value::Timestamp(t)) = evaluated.first()
3839        && args.first().is_some_and(|a| {
3840            crate::describe::describe_expr(a, ctx.columns)
3841                .is_some_and(|sh| matches!(sh.ty, spg_storage::DataType::Timestamptz))
3842        })
3843    {
3844        let off = ctx.session_tz_offset_at(*t);
3845        let local = t + off;
3846        let days = local.div_euclid(86_400_000_000);
3847        let day_us = local.rem_euclid(86_400_000_000);
3848        let (y, mo, d) = civil_from_days(i32::try_from(days).unwrap_or(0));
3849        let secs = day_us / 1_000_000;
3850        let frac = day_us % 1_000_000;
3851        let (hh, mi, ss) = (secs / 3600, (secs / 60) % 60, secs % 60);
3852        let mut txt = alloc::format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mi:02}:{ss:02}");
3853        if frac != 0 {
3854            let f = alloc::format!("{frac:06}");
3855            txt.push('.');
3856            txt.push_str(f.trim_end_matches('0'));
3857        }
3858        let (sign, omag) = if off < 0 { ('-', -off) } else { ('+', off) };
3859        let (oh, om) = (omag / 3_600_000_000, (omag / 60_000_000) % 60);
3860        let _ = core::fmt::Write::write_fmt(&mut txt, format_args!("{sign}{oh:02}:{om:02}"));
3861        return Ok(Value::json(alloc::format!("\"{txt}\"")));
3862    }
3863    // v7.39 (enum order knife) — greatest/least over enum-typed arguments
3864    // pick by member order, not label text (PG). The witness needs the arg
3865    // ASTs, so this can't live in the value-level function dispatch.
3866    if (name.eq_ignore_ascii_case("greatest") || name.eq_ignore_ascii_case("least"))
3867        && let Some(labels) = args
3868            .iter()
3869            .find_map(|a| expr_enum_labels(a, ctx.columns, ctx.catalog))
3870        && evaluated
3871            .iter()
3872            .all(|v| matches!(v, Value::Text(_) | Value::Null))
3873    {
3874        let is_greatest = name.eq_ignore_ascii_case("greatest");
3875        let mut best: Option<&Value<'static>> = None;
3876        for v in evaluated.iter().filter(|v| !matches!(v, Value::Null)) {
3877            best = Some(match best {
3878                None => v,
3879                Some(b) => match enum_ord_cmp(labels, v, b) {
3880                    Some(core::cmp::Ordering::Greater) if is_greatest => v,
3881                    Some(core::cmp::Ordering::Less) if !is_greatest => v,
3882                    Some(_) => b,
3883                    // A non-member snuck in — fall out to the generic path.
3884                    None => return apply_function(name, &evaluated, ctx),
3885                },
3886            });
3887        }
3888        return Ok(best.cloned().unwrap_or(Value::Null));
3889    }
3890    // v7.39 (round 693) — and the same for a declared COLLATION, which
3891    // `least`/`greatest` need for the same structural reason: the witness
3892    // is the argument's column, so it cannot live in the value-level
3893    // dispatch either. Measured on PG18 over a column declaring
3894    // en_US.utf8: `least(a,'d')` is `d` and `greatest(a,'d')` is `Zebra`,
3895    // where byte order gives the pair reversed.
3896    if (name.eq_ignore_ascii_case("greatest") || name.eq_ignore_ascii_case("least"))
3897        && evaluated
3898            .iter()
3899            .all(|v| matches!(v, Value::Text(_) | Value::Null))
3900        && let Some(coll) = greatest_least_collation(args, ctx)
3901    {
3902        let is_greatest = name.eq_ignore_ascii_case("greatest");
3903        let mut best: Option<&Value<'static>> = None;
3904        for v in evaluated.iter().filter(|v| !matches!(v, Value::Null)) {
3905            best = Some(match (best, v) {
3906                (None, _) => v,
3907                (Some(Value::Text(y)), Value::Text(x)) => {
3908                    match crate::collate::compare(&coll, x, y) {
3909                        Some(core::cmp::Ordering::Greater) if is_greatest => v,
3910                        Some(core::cmp::Ordering::Less) if !is_greatest => v,
3911                        Some(_) => best.unwrap_or(v),
3912                        // Not a collation this build performs after all —
3913                        // one answer, from the generic path.
3914                        None => return apply_function(name, &evaluated, ctx),
3915                    }
3916                }
3917                (Some(b), _) => b,
3918            });
3919        }
3920        return Ok(best.cloned().unwrap_or(Value::Null));
3921    }
3922    // v7.39 (round 621) — an unadorned string literal takes the type the
3923    // function's parameter asks for. `justify_interval('36 hours')` is answered
3924    // by PG and was refused here, and so were `justify_days('35 days')` and
3925    // `justify_hours('27 hours')` — the spelling everyone writes, since typing
3926    // `INTERVAL` in front of the literal is exactly what PG saves you from.
3927    //
3928    // Only a LITERAL is resolved, which is the same boundary round 620 drew for
3929    // the boolean connectives: `justify_interval(t)` over a TEXT column stays
3930    // refused, because PG refuses it too (no such overload). The arg ASTs are
3931    // needed to tell those apart, so this cannot live in the value-level
3932    // dispatch — the same reason the enum witness above sits here.
3933    if let Some(want) = unknown_literal_param_type(name) {
3934        let mut coerced = evaluated;
3935        for (i, a) in args.iter().enumerate() {
3936            if is_unknown_string_literal(a)
3937                && let Some(slot) = coerced.get_mut(i)
3938            {
3939                *slot = cast::cast_value_in(
3940                    core::mem::replace(slot, Value::Null),
3941                    want.clone(),
3942                    false,
3943                )?;
3944            }
3945        }
3946        return apply_function(name, &coerced, ctx);
3947    }
3948    apply_function(name, &evaluated, ctx)
3949}
3950
3951/// v7.39 (round 621) — the parameter type a bare string literal resolves to.
3952///
3953/// PG resolves an `unknown` literal to whatever the chosen overload declares.
3954/// SPG has no overload resolution to hang that on, so the functions whose only
3955/// parameter is unambiguous are listed. `None` leaves the argument alone.
3956fn unknown_literal_param_type(name: &str) -> Option<spg_sql::ast::CastTarget> {
3957    match name.to_ascii_lowercase().as_str() {
3958        "justify_days" | "justify_hours" | "justify_interval" => {
3959            Some(spg_sql::ast::CastTarget::Interval)
3960        }
3961        _ => None,
3962    }
3963}
3964
3965/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
3966/// (stack-depth guard budget); body unchanged.
3967#[inline(never)]
3968fn eval_any_all_arm(
3969    expr: &Expr,
3970    op: &BinOp,
3971    array: &Expr,
3972    is_any: bool,
3973    row: &Row<'static>,
3974    ctx: &EvalContext<'_>,
3975) -> Result<Value<'static>, EvalError> {
3976    let lhs = eval_expr(expr, row, ctx)?;
3977    let arr = eval_expr(array, row, ctx)?;
3978    any_all_over(lhs, arr, op, is_any)
3979}
3980
3981/// v7.39 (round 597) — the ANY/ALL comparison with both sides already in
3982/// hand. Split out so a CONSTANT right-hand array can be built once at
3983/// compile time instead of once per row: `WHERE id = ANY (ARRAY[1..10])`
3984/// rebuilt the array for all 500k rows and cost 268 ms against PG18's 8.3,
3985/// rising to 494 ms at twenty elements, while the equivalent
3986/// `id IN (1..10)` took 2.3. The body below is unchanged; it never touched
3987/// `row`.
3988pub(crate) fn any_all_over(
3989    lhs: Value<'static>,
3990    arr: Value<'static>,
3991    op: &BinOp,
3992    is_any: bool,
3993) -> Result<Value<'static>, EvalError> {
3994    if matches!(arr, Value::Null) {
3995        return Ok(Value::Null);
3996    }
3997    // v7.38 (read01) — an unknown-string RHS (`x = ANY('{1,2,3}')`)
3998    // takes the LHS's type: coerce the external array text to the array
3999    // type matching the LHS's element type, like PG.
4000    let arr = match &arr {
4001        Value::Text(_) => {
4002            // The LHS's element type, or TEXT when the LHS is an
4003            // untyped NULL (PG's unknown → text default).
4004            let arr_ty = match lhs.data_type() {
4005                Some(spg_storage::DataType::SmallInt) => spg_storage::DataType::SmallIntArray,
4006                Some(spg_storage::DataType::Int) => spg_storage::DataType::IntArray,
4007                Some(spg_storage::DataType::BigInt) => spg_storage::DataType::BigIntArray,
4008                Some(spg_storage::DataType::Numeric { .. }) => spg_storage::DataType::NumericArray,
4009                Some(spg_storage::DataType::Float) => spg_storage::DataType::FloatArray,
4010                Some(spg_storage::DataType::Bool) => spg_storage::DataType::BoolArray,
4011                Some(spg_storage::DataType::Date) => spg_storage::DataType::DateArray,
4012                _ => spg_storage::DataType::TextArray,
4013            };
4014            crate::conversions::coerce_value(arr.clone(), arr_ty, "", 0).unwrap_or(arr)
4015        }
4016        _ => arr,
4017    };
4018    // Build the element list generically so every scalar array type
4019    // (numeric[], float8[], bool[], date[], …) is accepted, not just
4020    // int/bigint/text.
4021    let Some(len) = array_len(&arr) else {
4022        return Err(EvalError::TypeMismatch {
4023            detail: format!(
4024                "ANY/ALL right-hand side must be an array, got {}",
4025                crate::conversions::pg_type_name_for_error_opt(arr.data_type())
4026            ),
4027        });
4028    };
4029    let elems: Vec<Option<Value>> = (0..len)
4030        .map(|i| match array_element_at(&arr, i) {
4031            Some(Value::Null) | None => None,
4032            Some(v) => Some(v),
4033        })
4034        .collect();
4035    // PG: `x op ANY (empty)` → false and `x op ALL (empty)` →
4036    // true, decided purely by emptiness — the comparison is
4037    // never evaluated, so a NULL LHS is irrelevant. This must
4038    // short-circuit before `saw_null` is seeded from the LHS,
4039    // otherwise `NULL op ANY/ALL (empty)` wrongly yields NULL.
4040    if elems.is_empty() {
4041        return Ok(Value::Bool(!is_any));
4042    }
4043    let mut saw_null = matches!(lhs, Value::Null);
4044    let mut saw_match = false;
4045    let mut saw_mismatch = false;
4046    for elem in elems {
4047        let elem_v = match elem {
4048            Some(v) => v,
4049            None => {
4050                saw_null = true;
4051                continue;
4052            }
4053        };
4054        if matches!(lhs, Value::Null) {
4055            saw_null = true;
4056            continue;
4057        }
4058        match apply_binary(*op, lhs.clone(), elem_v) {
4059            Ok(Value::Bool(true)) => saw_match = true,
4060            Ok(Value::Bool(false)) => saw_mismatch = true,
4061            Ok(Value::Null) => saw_null = true,
4062            Ok(other) => {
4063                return Err(EvalError::TypeMismatch {
4064                    detail: format!(
4065                        "ANY/ALL comparison didn't return Bool: {}",
4066                        crate::conversions::pg_type_name_for_error_opt(other.data_type())
4067                    ),
4068                });
4069            }
4070            Err(e) => return Err(e),
4071        }
4072    }
4073    let result = if is_any {
4074        if saw_match {
4075            Value::Bool(true)
4076        } else if saw_null {
4077            Value::Null
4078        } else {
4079            Value::Bool(false)
4080        }
4081    } else if saw_mismatch {
4082        Value::Bool(false)
4083    } else if saw_null {
4084        Value::Null
4085    } else {
4086        Value::Bool(true)
4087    };
4088    Ok(result)
4089}
4090
4091/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4092/// (stack-depth guard budget); body unchanged.
4093#[inline(never)]
4094fn eval_case_arm(
4095    operand: &Option<alloc::boxed::Box<Expr>>,
4096    branches: &[(Expr, Expr)],
4097    else_branch: &Option<alloc::boxed::Box<Expr>>,
4098    row: &Row<'static>,
4099    ctx: &EvalContext<'_>,
4100) -> Result<Value<'static>, EvalError> {
4101    // v7.39 (round 237) — PG resolves the RESULT branches to one type and
4102    // refuses the CASE when they have no common one, before running
4103    // anything. SPG returned whichever branch fired, so
4104    // `CASE WHEN true THEN 1 ELSE 'a'::text END` answered `1` and the same
4105    // expression answered text on another row.
4106    {
4107        // PG resolves the ELSE branch FIRST and then the WHEN results, and
4108        // its message names the running type before the conflicting one —
4109        // which is why `THEN 1 ELSE 'a'::text` reports "text and integer"
4110        // while a two-WHEN `THEN 1 ... THEN true` reports "integer and
4111        // boolean". Probed against 18.4; the order is observable.
4112        let mut results: Vec<&Expr> = Vec::with_capacity(branches.len() + 1);
4113        if let Some(e) = else_branch {
4114            results.push(e);
4115        }
4116        results.extend(branches.iter().map(|(_, r)| r));
4117        unify_branch_types_static("CASE", results, ctx)?;
4118    }
4119    let operand_value = match operand {
4120        Some(o) => Some(eval_expr(o, row, ctx)?),
4121        None => None,
4122    };
4123    // v7.37 D.1 — CASE result-type coercion (same rule as COALESCE): a
4124    // typed result branch (`... THEN '10:00'::time`) makes the whole
4125    // CASE that type, so an untyped string-literal branch is coerced to
4126    // it. Compute the hint once from every THEN/ELSE branch.
4127    let case_hint = branches
4128        .iter()
4129        .map(|(_, t)| t)
4130        .chain(else_branch.iter().map(|b| b.as_ref()))
4131        .find_map(coalesce_type_hint);
4132    // v7.38 (read01) — the CASE result is PG's common type of every
4133    // THEN/ELSE branch, so a taken integer branch is widened to
4134    // numeric when a sibling branch is numeric (and `pg_typeof` /
4135    // downstream division match PG). Only one branch is evaluated, so
4136    // the type must come from the branch expressions statically.
4137    let branch_types: Vec<spg_storage::DataType> = branches
4138        .iter()
4139        .map(|(_, t)| t)
4140        .chain(else_branch.iter().map(|b| b.as_ref()))
4141        .filter_map(|e| crate::describe::describe_expr(e, ctx.columns).map(|s| s.ty))
4142        .collect();
4143    let coerce = |v: Value<'static>| -> Result<Value<'static>, EvalError> {
4144        let v = match (&v, &case_hint) {
4145            (Value::Text(_), Some(target)) => cast::cast_value(v, target.clone())?,
4146            _ => v,
4147        };
4148        Ok(widen_to_common(v, &branch_types))
4149    };
4150    for (when_expr, then_expr) in branches {
4151        let when_value = eval_expr(when_expr, row, ctx)?;
4152        let matched = match &operand_value {
4153            // v7.39 (round 346, M1) — the WHEN condition is a truth value,
4154            // not a boolean-shaped one: `CASE WHEN 1 THEN 'a' END` used to
4155            // answer NULL in BOTH dialects, where MariaDB answers `a` and
4156            // PG raises `argument of CASE/WHEN must be type boolean`.
4157            None => predicate_is_true(&when_value, "CASE/WHEN", ctx.mysql_dialect)?,
4158            // v7.39 (round 412) — under the MySQL default collation the
4159            // `CASE op WHEN v` equality folds Text/BpChar operands (CI +
4160            // accent + PAD SPACE), matching `op = v` outside CASE.
4161            Some(op_v) => {
4162                let (l, r) = if ctx.mysql_dialect {
4163                    match (op_v, &when_value) {
4164                        (Value::Text(x), Value::Text(y)) | (Value::BpChar(x), Value::BpChar(y)) => {
4165                            (
4166                                Value::text(spg_storage::mysql_compare_fold(x)),
4167                                Value::text(spg_storage::mysql_compare_fold(y)),
4168                            )
4169                        }
4170                        _ => (op_v.clone(), when_value),
4171                    }
4172                } else {
4173                    (op_v.clone(), when_value)
4174                };
4175                matches!(
4176                    apply_binary(spg_sql::ast::BinOp::Eq, l, r)?,
4177                    Value::Bool(true)
4178                )
4179            }
4180        };
4181        if matched {
4182            return coerce(eval_expr(then_expr, row, ctx)?);
4183        }
4184    }
4185    match else_branch {
4186        Some(e) => coerce(eval_expr(e, row, ctx)?),
4187        None => Ok(Value::Null),
4188    }
4189}
4190
4191/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4192/// (stack-depth guard budget); body unchanged.
4193#[inline(never)]
4194fn eval_array_slice_arm(
4195    target: &Expr,
4196    lo: &Option<alloc::boxed::Box<Expr>>,
4197    hi: &Option<alloc::boxed::Box<Expr>>,
4198    row: &Row<'static>,
4199    ctx: &EvalContext<'_>,
4200) -> Result<Value<'static>, EvalError> {
4201    let target_v = eval_expr(target, row, ctx)?;
4202    if matches!(target_v, Value::Null) {
4203        return Ok(Value::Null);
4204    }
4205    let bound = |e: Option<&Expr>| -> Result<Option<i64>, EvalError> {
4206        match e {
4207            None => Ok(None),
4208            Some(b) => match eval_expr(b, row, ctx)? {
4209                Value::Null => Ok(None),
4210                Value::Int(n) => Ok(Some(i64::from(n))),
4211                Value::BigInt(n) => Ok(Some(n)),
4212                Value::SmallInt(n) => Ok(Some(i64::from(n))),
4213                other => Err(EvalError::TypeMismatch {
4214                    detail: format!(
4215                        "array slice bound must be integer, got {}",
4216                        crate::conversions::pg_type_name_for_error_opt(other.data_type())
4217                    ),
4218                }),
4219            },
4220        }
4221    };
4222    let lo_b = bound(lo.as_deref())?;
4223    let hi_b = bound(hi.as_deref())?;
4224    fn window(len: usize, lo: Option<i64>, hi: Option<i64>) -> (usize, usize) {
4225        let start = lo.map_or(0, |l| (l.max(1) - 1) as usize).min(len);
4226        let end = hi.map_or(len, |h| h.max(0) as usize).min(len);
4227        (start, end.max(start))
4228    }
4229    match target_v {
4230        Value::TextArray(items) => {
4231            let (s, e) = window(items.len(), lo_b, hi_b);
4232            Ok(Value::TextArray(items[s..e].to_vec()))
4233        }
4234        Value::IntArray(items) => {
4235            let (s, e) = window(items.len(), lo_b, hi_b);
4236            Ok(Value::IntArray(items[s..e].to_vec()))
4237        }
4238        Value::BigIntArray(items) => {
4239            let (s, e) = window(items.len(), lo_b, hi_b);
4240            Ok(Value::BigIntArray(items[s..e].to_vec()))
4241        }
4242        other => Err(EvalError::TypeMismatch {
4243            detail: format!(
4244                "slice target must be an array, got {}",
4245                crate::conversions::pg_type_name_for_error_opt(other.data_type())
4246            ),
4247        }),
4248    }
4249}
4250
4251/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4252/// (stack-depth guard budget); body unchanged.
4253#[inline(never)]
4254fn eval_in_list_arm(
4255    expr: &Expr,
4256    list: &[Expr],
4257    negated: bool,
4258    row: &Row<'static>,
4259    ctx: &EvalContext<'_>,
4260) -> Result<Value<'static>, EvalError> {
4261    // v7.39 (round 238) — PG resolves the whole list's type BEFORE comparing
4262    // anything, so `1 IN (1, 'a'::text)` is refused. SPG compared item by
4263    // item and broke on the first match, so the offending element was never
4264    // reached and the predicate quietly answered true. Checked statically,
4265    // like round 237: evaluating the rest of the list to inspect it would
4266    // change when side effects fire.
4267    require_in_list_comparable(expr, list, ctx)?;
4268    // v7.39 (round 364, M4 P2) — a MySQL session folds text before the
4269    // membership test, so `t IN ('FOO')` matches 'Foo'. `BINARY` is not
4270    // reachable through a bare column needle here; the fold is text-only.
4271    // v7.39 (round 370, M4 P4a) — an explicit `COLLATE utf8mb4_bin` needle
4272    // column is byte-wise, so it does not fold. v7.39 (round 371, M4 P4b) —
4273    // a per-expression `… COLLATE utf8mb4_bin` / `BINARY …` on the needle
4274    // OR any list item forces the whole membership test byte-wise.
4275    let in_fold = ctx.mysql_dialect
4276        && !resolve::operand_is_binary_column(expr, ctx)
4277        && !resolve::is_binary_coerced(expr)
4278        && !list.iter().any(|i| resolve::is_binary_coerced(i));
4279    let needle = mysql_collation_key(eval_expr(expr, row, ctx)?, in_fold);
4280    let needle_null = matches!(needle, Value::Null);
4281    let mut saw_null = needle_null && !list.is_empty();
4282    let mut matched = false;
4283    if !needle_null {
4284        for item in list {
4285            let v = mysql_collation_key(eval_expr(item, row, ctx)?, in_fold);
4286            if matches!(v, Value::Null) {
4287                saw_null = true;
4288                continue;
4289            }
4290            match apply_binary(BinOp::Eq, needle.clone(), v)? {
4291                Value::Bool(true) => {
4292                    matched = true;
4293                    break;
4294                }
4295                Value::Bool(false) => {}
4296                Value::Null => saw_null = true,
4297                other => {
4298                    return Err(EvalError::TypeMismatch {
4299                        detail: format!(
4300                            "IN comparison didn't return Bool: {}",
4301                            crate::conversions::pg_type_name_for_error_opt(other.data_type())
4302                        ),
4303                    });
4304                }
4305            }
4306        }
4307    }
4308    let inner = if matched {
4309        Value::Bool(true)
4310    } else if saw_null {
4311        Value::Null
4312    } else {
4313        Value::Bool(false)
4314    };
4315    Ok(match (negated, inner) {
4316        (true, Value::Bool(b)) => Value::Bool(!b),
4317        (_, v) => v,
4318    })
4319}
4320
4321/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4322/// (stack-depth guard budget); body unchanged.
4323#[inline(never)]
4324fn eval_like_arm(
4325    expr: &Expr,
4326    pattern: &Expr,
4327    negated: bool,
4328    case_insensitive: bool,
4329    row: &Row<'static>,
4330    ctx: &EvalContext<'_>,
4331) -> Result<Value<'static>, EvalError> {
4332    let v = eval_expr(expr, row, ctx)?;
4333    let p = eval_expr(pattern, row, ctx)?;
4334    // NULL on either side propagates to NULL — same as PG.
4335    // v7.39 (bpchar epic) — LIKE matches bpchar on its PADDED
4336    // stored form, per PG's bpchar pattern operators.
4337    let (text, pat) = match (v, p) {
4338        (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
4339        (Value::Text(a) | Value::BpChar(a), Value::Text(b) | Value::BpChar(b)) => (a, b),
4340        (Value::Text(_) | Value::BpChar(_), other) | (other, _) => {
4341            return Err(EvalError::TypeMismatch {
4342                detail: format!(
4343                    "LIKE requires text operands, got {}",
4344                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
4345                ),
4346            });
4347        }
4348    };
4349    // v7.25 (round-17) — ILIKE folds both operands (PG
4350    // lowercases per the default collation).
4351    // v7.39 (round 364, M4 P2) — a MySQL session's default collation is
4352    // accent- and case-insensitive, so `LIKE` folds both sides the way
4353    // `ILIKE` does; the wildcards `%` / `_` are not Latin letters so the
4354    // fold leaves them alone.
4355    // v7.39 (round 370, M4 P4a) — an explicit `COLLATE utf8mb4_bin` column
4356    // matches byte-wise, so it does not fold. v7.39 (round 371, M4 P4b) —
4357    // a per-expression `… COLLATE utf8mb4_bin` / `BINARY …` on either the
4358    // value or the pattern forces byte-wise too.
4359    let mysql = ctx.mysql_dialect
4360        && !resolve::operand_is_binary_column(expr, ctx)
4361        && !resolve::operand_is_binary_column(pattern, ctx)
4362        && !resolve::is_binary_coerced(expr)
4363        && !resolve::is_binary_coerced(pattern);
4364    let m = if case_insensitive {
4365        like_match(&text.to_lowercase(), &pat.to_lowercase())?
4366    } else if mysql {
4367        like_match(
4368            &spg_storage::mysql_ci_fold(&text),
4369            &spg_storage::mysql_ci_fold(&pat),
4370        )?
4371    } else {
4372        like_match(&text, &pat)?
4373    };
4374    Ok(Value::Bool(if negated { !m } else { m }))
4375}
4376
4377/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4378/// (stack-depth guard budget); body unchanged.
4379#[inline(never)]
4380fn eval_extract_arm(
4381    field: &spg_sql::ast::ExtractField,
4382    source: &Expr,
4383    row: &Row<'static>,
4384    ctx: &EvalContext<'_>,
4385) -> Result<Value<'static>, EvalError> {
4386    let v = eval_expr(source, row, ctx)?;
4387    extract_from_value(field, v, source, ctx)
4388}
4389
4390/// v7.39 (round 595) — the field extraction, with the source value already
4391/// in hand. Split out so the compiled-predicate program can pop the source
4392/// off its stack instead of handing the whole node back to the interpreter:
4393/// one non-compilable node used to disqualify the entire WHERE, and
4394/// `WHERE extract(year FROM t) = 2020` was interpreting the column read and
4395/// the comparison too. The body below is unchanged; it never touched `row`.
4396pub(crate) fn extract_from_value(
4397    field: &spg_sql::ast::ExtractField,
4398    v: Value<'static>,
4399    source: &Expr,
4400    ctx: &EvalContext<'_>,
4401) -> Result<Value<'static>, EvalError> {
4402    // v7.39 (round 382) — MySQL coerces a date/time STRING to its temporal
4403    // value for EXTRACT (`EXTRACT(YEAR FROM '2020-05-15')` is 2020, and the
4404    // time fields read a `'... HH:MM:SS'` string); PG needs a typed source.
4405    let v = match &v {
4406        Value::Text(s) if ctx.mysql_dialect => text_as_temporal(s).unwrap_or(v),
4407        _ => v,
4408    };
4409    // v7.39 (tz epic) — timezone[_hour|_minute] of a timestamptz
4410    // reports the SESSION offset at that instant (PG: 32400 for
4411    // Tokyo; -14400 for New York in July).
4412    if matches!(
4413        field,
4414        spg_sql::ast::ExtractField::Timezone
4415            | spg_sql::ast::ExtractField::TimezoneHour
4416            | spg_sql::ast::ExtractField::TimezoneMinute
4417    ) && let Value::Timestamp(t) = &v
4418        && crate::describe::describe_expr(source, ctx.columns)
4419            .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz))
4420    {
4421        let off_secs = ctx.session_tz_offset_at(*t) / 1_000_000;
4422        let n = match field {
4423            spg_sql::ast::ExtractField::Timezone => off_secs,
4424            spg_sql::ast::ExtractField::TimezoneHour => off_secs / 3600,
4425            _ => (off_secs / 60) % 60,
4426        };
4427        // v7.39 (round 253) — numeric, like every other EXTRACT result.
4428        return Ok(Value::Numeric {
4429            scaled: i128::from(n),
4430            scale: 0,
4431            kind: spg_storage::NumericKind::Finite,
4432        });
4433    }
4434    // v7.39 (round 523) — and every OTHER field of a timestamptz reads
4435    // the local clock in the session zone, which is the whole reason PG
4436    // has the type. `extract(hour from …)` answered the UTC hour under
4437    // `SET TimeZone = 'Asia/Tokyo'` — 0 where PG says 9 — and
4438    // `extract(dow …)` therefore named the wrong DAY, so a report
4439    // grouped by weekday put nine hours of every Sunday under Saturday.
4440    // Only fields of the local clock shift; epoch and julian are
4441    // absolute, and the timezone fields answered above.
4442    let v = match &v {
4443        Value::Timestamp(t)
4444            if !matches!(
4445                field,
4446                spg_sql::ast::ExtractField::Epoch
4447                    | spg_sql::ast::ExtractField::Julian
4448                    | spg_sql::ast::ExtractField::Timezone
4449                    | spg_sql::ast::ExtractField::TimezoneHour
4450                    | spg_sql::ast::ExtractField::TimezoneMinute
4451            ) && crate::describe::describe_expr(source, ctx.columns)
4452                .is_some_and(|s| matches!(s.ty, spg_storage::DataType::Timestamptz)) =>
4453        {
4454            Value::Timestamp(t.saturating_add(ctx.session_tz_offset_at(*t)))
4455        }
4456        _ => v,
4457    };
4458    // v7.39 (round 253) — the source's PG type name for error wording,
4459    // upgraded from the declared type when statically known (a tstz
4460    // VALUE is indistinguishable from a timestamp). Only a cast /
4461    // column is trusted (the r237 lesson: describe_expr reports a
4462    // binary operator as its left operand's type).
4463    let static_declared = matches!(source, Expr::Cast { .. } | Expr::Column(_))
4464        .then(|| crate::describe::describe_expr(source, ctx.columns))
4465        .flatten()
4466        .map(|sch| sch.ty);
4467    let src_name = match static_declared {
4468        Some(spg_storage::DataType::Timestamptz) => "timestamp with time zone",
4469        _ => datetime::value_src_type_name(&v),
4470    };
4471    // PG rejects the timezone family on a plain timestamp (0A000);
4472    // only reject when the declared type is STATICALLY timestamp — a
4473    // dynamic value stays lenient (the pre-r253 zero answer).
4474    if matches!(
4475        field,
4476        spg_sql::ast::ExtractField::Timezone
4477            | spg_sql::ast::ExtractField::TimezoneHour
4478            | spg_sql::ast::ExtractField::TimezoneMinute
4479    ) && matches!(static_declared, Some(spg_storage::DataType::Timestamp))
4480    {
4481        return Err(EvalError::TypeMismatch {
4482            detail: alloc::format!(
4483                "unit \"{}\" not supported for type timestamp without time zone",
4484                alloc::format!("{field}").to_lowercase()
4485            ),
4486        });
4487    }
4488    // v7.39 (round 418) — MySQL's compound units (`DAY_SECOND`, `YEAR_MONTH`,
4489    // …) reach here as `ExtractField::Other`, which PG rejects. Under the
4490    // MySQL dialect they pack several components into one integer instead.
4491    if ctx.mysql_dialect
4492        && let spg_sql::ast::ExtractField::Other(name) = field
4493        && let Some(packed) = crate::eval::datetime::mysql_compound_extract(name, &v)
4494    {
4495        return Ok(packed);
4496    }
4497    extract_field(field, &v, src_name)
4498}
4499
4500/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4501/// (stack-depth guard budget); body unchanged.
4502#[inline(never)]
4503fn eval_array_subscript_arm(
4504    expr: &Expr,
4505    row: &Row<'static>,
4506    ctx: &EvalContext<'_>,
4507) -> Result<Value<'static>, EvalError> {
4508    // Collect the whole subscript chain so PG's multi-dimensional
4509    // access (`arr[i][j]` is ONE N-subscript op) is distinguishable
4510    // from chained 1-D indexing. `arr[1][2]` parses as
4511    // `(arr[1])[2]`; PG indexes the matrix directly and returns NULL
4512    // for a partial subscript (`arr[1]` on a 2-D array is NULL).
4513    let mut idx_exprs: Vec<&Expr> = Vec::new();
4514    let mut base = expr;
4515    while let Expr::ArraySubscript { target, index } = base {
4516        idx_exprs.push(index);
4517        base = target;
4518    }
4519    idx_exprs.reverse();
4520    let base_v = eval_expr(base, row, ctx)?;
4521    if matches!(
4522        base_v,
4523        Value::IntArray2D(_)
4524            | Value::BigIntArray2D(_)
4525            | Value::TextArray2D(_)
4526            | Value::BoolArray2D(_)
4527    ) {
4528        return eval_matrix_subscript(&base_v, &idx_exprs, row, ctx);
4529    }
4530    // 1-D array / JSON: apply each subscript left-to-right. This
4531    // reproduces the prior single-subscript semantics exactly, and
4532    // chained JSON (`j['a']['b']`) still resolves step by step.
4533    let mut cur = base_v;
4534    for ix in idx_exprs {
4535        cur = apply_one_subscript(cur, ix, row, ctx)?;
4536    }
4537    Ok(cur)
4538}
4539
4540/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4541/// (stack-depth guard budget); body unchanged.
4542#[inline(never)]
4543fn eval_field_access_arm(
4544    base: &Expr,
4545    field: &str,
4546    row: &Row<'static>,
4547    ctx: &EvalContext<'_>,
4548) -> Result<Value<'static>, EvalError> {
4549    // v7.38 (read01, T9) — composite field access `(expr).field`.
4550    // The base evaluates to a record; look the member up by name
4551    // (`f1`..`fN` for an anonymous ROW, base column names for a
4552    // whole-row). A NULL record yields NULL (PG semantics).
4553    let v = eval_expr(base, row, ctx)?;
4554    match v {
4555        Value::Null => Ok(Value::Null),
4556        Value::Composite(fields) => fields
4557            .into_iter()
4558            .find(|(name, _)| name == field)
4559            .map(|(_, val)| val)
4560            .ok_or_else(|| missing_field_error(base, field, ctx)),
4561        _ => Err(not_a_composite_error(base, field, ctx)),
4562    }
4563}
4564
4565/// v7.39 (round 285) — PG words a missing composite field three ways, and
4566/// which one you get depends on the base expression's STATIC type, not on
4567/// the value:
4568///
4569///   * a named composite — `column "nosuch" not found in data type rc9`
4570///   * a whole-row table reference — `column rt8.nosuch does not exist`
4571///     (unquoted, and qualified — the odd one out)
4572///   * an anonymous ROW or `::record` — `could not identify column
4573///     "nosuch" in record data type`
4574///
4575/// All three read off live PG 18.4. A `Value::Composite` carries its field
4576/// names but not its type name, so the base expression is what decides.
4577fn missing_field_error(base: &Expr, field: &str, ctx: &EvalContext<'_>) -> EvalError {
4578    // v7.39 (round 307, V25) — the named type may arrive by cast OR from
4579    // the schema of a column that a projection produced, so ask once and
4580    // let the catalog say whether it is a composite. Before this only
4581    // the cast spelling was recognised, which is why a composite that
4582    // came through a derived table or a CTE — where the base is a plain
4583    // column — fell through to the anonymous-record wording.
4584    if let Some(name) = base_named_type(base, ctx)
4585        && ctx
4586            .catalog
4587            .is_some_and(|c| c.composite_types().contains_key(name))
4588    {
4589        return EvalError::TypeMismatch {
4590            detail: alloc::format!("column \"{field}\" not found in data type {name}"),
4591        };
4592    }
4593    if let Expr::Column(c) = base
4594        && c.qualifier.is_none()
4595    {
4596        // A column DECLARED as a named composite reports that type — the
4597        // schema records it in `user_composite_type`, which is the only
4598        // place the name survives (a `Value::Composite` does not carry it).
4599        if let Some(name) = ctx
4600            .columns
4601            .iter()
4602            .find(|col| col.name.eq_ignore_ascii_case(&c.name))
4603            .and_then(|col| col.user_composite_type.as_ref())
4604        {
4605            return EvalError::TypeMismatch {
4606                detail: alloc::format!("column \"{field}\" not found in data type {name}"),
4607            };
4608        }
4609        // A whole-row reference to a real table is the odd wording out:
4610        // qualified, and unquoted.
4611        if ctx.catalog.is_some_and(|cat| cat.get(&c.name).is_some()) {
4612            return EvalError::TypeMismatch {
4613                detail: alloc::format!("column {}.{field} does not exist", c.name),
4614            };
4615        }
4616    }
4617    EvalError::TypeMismatch {
4618        detail: alloc::format!("could not identify column \"{field}\" in record data type"),
4619    }
4620}
4621
4622/// v7.39 (round 307, V25) — the user-declared type name behind a field
4623/// access, if any: either written as a cast (`ROW(…)::rc9`) or carried on
4624/// the column's schema.
4625///
4626/// All three schema slots are consulted rather than just the composite
4627/// one. A projection currently files the name of a `::rc9` cast under
4628/// `user_enum_type` — `expr_enum_type_name` answers for ANY named cast,
4629/// without asking whether the name is an enum — so keying off one slot
4630/// would answer for some shapes and not others. The caller decides what
4631/// the name MEANS by asking the catalog, which is the only thing that
4632/// actually knows; this function just finds it.
4633fn base_named_type<'c>(base: &'c Expr, ctx: &'c EvalContext<'_>) -> Option<&'c str> {
4634    match base {
4635        Expr::Cast {
4636            target: CastTarget::Named(name),
4637            ..
4638        } => Some(name.as_str()),
4639        Expr::Column(c) => ctx
4640            .columns
4641            .iter()
4642            .find(|sc| sc.name.eq_ignore_ascii_case(&c.name))
4643            .and_then(|sc| {
4644                sc.user_composite_type
4645                    .as_deref()
4646                    .or(sc.user_domain_type.as_deref())
4647                    .or(sc.user_enum_type.as_deref())
4648            }),
4649        _ => None,
4650    }
4651}
4652
4653/// v7.39 (round 307, V25) — PG's wording when field notation is applied
4654/// to something that is not a composite at all. It names the type:
4655/// `column notation .f applied to type pos9, which is not a composite
4656/// type` — for a domain and an enum alike. Only when the base has no
4657/// user-declared type at all does the generic message stand.
4658fn not_a_composite_error(base: &Expr, field: &str, ctx: &EvalContext<'_>) -> EvalError {
4659    if let Some(name) = base_named_type(base, ctx)
4660        && ctx.catalog.is_some_and(|c| {
4661            c.enum_types().contains_key(name) || c.domain_types().contains_key(name)
4662        })
4663    {
4664        return EvalError::TypeMismatch {
4665            detail: alloc::format!(
4666                "column notation .{field} applied to type {name}, which is not a composite type"
4667            ),
4668        };
4669    }
4670    EvalError::TypeMismatch {
4671        detail: alloc::format!("field access `.{field}` requires a composite (record) value"),
4672    }
4673}
4674
4675/// Out-of-lined `eval_expr` arm — keeps the recursive frame small
4676/// (stack-depth guard budget); body unchanged.
4677#[inline(never)]
4678/// v7.39 (round 328, V45) — the three-valued boolean tests. None of them
4679/// ever answers NULL: a NULL input is "not true" and "not false", and IS
4680/// UNKNOWN is precisely the NULL case. Verified against PG 18.4 —
4681/// `NULL::bool IS TRUE` is false, `IS NOT TRUE` true, `IS UNKNOWN` true,
4682/// and `false IS NOT FALSE` false.
4683fn eval_bool_test_arm(
4684    expr: &Expr,
4685    value: Option<bool>,
4686    negated: bool,
4687    row: &Row<'static>,
4688    ctx: &EvalContext<'_>,
4689) -> Result<Value<'static>, EvalError> {
4690    let v = eval_expr(expr, row, ctx)?;
4691    let hit = match (value, &v) {
4692        // IS UNKNOWN — the input is NULL.
4693        (None, Value::Null) => true,
4694        // v7.39 (round 625) — and PG rejects a non-boolean here too:
4695        // `argument of IS UNKNOWN must be type boolean`. MySQL has no
4696        // IS UNKNOWN, so there is no dialect branch.
4697        (None, Value::Bool(_)) => false,
4698        (None, other) => {
4699            return Err(EvalError::TypeMismatch {
4700                detail: alloc::format!(
4701                    "argument of IS {}UNKNOWN must be type boolean, not type {}",
4702                    if negated { "NOT " } else { "" },
4703                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
4704                ),
4705            });
4706        }
4707        (Some(_), Value::Null) => false,
4708        (Some(want), Value::Bool(b)) => *b == want,
4709        // v7.39 (round 397) — MySQL reads a non-boolean as a truth value
4710        // for `IS TRUE` / `IS FALSE` (`5 IS TRUE` is 1, `0 IS FALSE` is 1,
4711        // `'abc' IS TRUE` is 0). PG rejects a non-boolean at parse time, so
4712        // this only fires under the dialect; a NULL is already handled.
4713        (Some(want), other) if ctx.mysql_dialect => mysql_truthy(other) == want,
4714        // v7.39 (round 625, S05b/F29) — on PG a non-boolean is REJECTED, and
4715        // the comment above said so while the arm below answered `false`
4716        // anyway. `1 IS TRUE` came back false, which reads as "the test was
4717        // run and did not hold" rather than "you cannot ask this of an
4718        // integer" — the wrong answer for every non-boolean type, eight of
4719        // them measured. PG's own sentence, which names the operator and the
4720        // type it got.
4721        (Some(_), other) => {
4722            return Err(EvalError::TypeMismatch {
4723                detail: alloc::format!(
4724                    "argument of IS {}{} must be type boolean, not type {}",
4725                    if negated { "NOT " } else { "" },
4726                    if value == Some(true) { "TRUE" } else { "FALSE" },
4727                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
4728                ),
4729            });
4730        }
4731    };
4732    Ok(Value::Bool(hit != negated))
4733}
4734
4735fn eval_is_null_arm(
4736    expr: &Expr,
4737    negated: bool,
4738    row: &Row<'static>,
4739    ctx: &EvalContext<'_>,
4740) -> Result<Value<'static>, EvalError> {
4741    // v7.38 (read01 P4.11) — `ROW(...) IS [NOT] NULL` is evaluated
4742    // field-wise, not as a whole-value null test: a row IS NULL when
4743    // every field is null, and IS NOT NULL when every field is
4744    // non-null — so the two are NOT simple negations (ROW(1,NULL) is
4745    // neither). A field that is itself a row is a non-null value, so
4746    // the check does not recurse. The `(a, b) IS NULL` tuple spelling
4747    // is already desugared to `a IS NULL AND b IS NULL` in the parser;
4748    // this covers the explicit `ROW(...)` constructor.
4749    if let Expr::FunctionCall { name, args } = expr
4750        && name.eq_ignore_ascii_case("row")
4751    {
4752        let mut all_null = true;
4753        let mut all_non_null = true;
4754        for a in args {
4755            if matches!(eval_expr(a, row, ctx)?, Value::Null) {
4756                all_non_null = false;
4757            } else {
4758                all_null = false;
4759            }
4760        }
4761        return Ok(Value::Bool(if negated { all_non_null } else { all_null }));
4762    }
4763    // v7.39 (round 962) — the same field-wise rule for a row-valued
4764    // EXPRESSION, not just the `ROW(...)` spelling. P4.11 keyed on the
4765    // syntax, so every other way to hold a row got the whole-value test:
4766    // measured against PG18.4, `SELECT an IS NULL FROM an` on a row whose
4767    // every column is NULL answered `t` there and `f` here, and a column
4768    // declared with a composite type behaved the same way. Round 961 made
4769    // whole-row references reachable through a projection, which is what
4770    // surfaced it.
4771    //
4772    // Fields are tested exactly as the `ROW(...)` arm tests its
4773    // arguments, without recursing — a field that is itself a row is a
4774    // non-null value.
4775    let v = eval_expr(expr, row, ctx)?;
4776    if let Value::Composite(fields) = &v {
4777        let mut all_null = true;
4778        let mut all_non_null = true;
4779        for (_, f) in fields {
4780            if matches!(f, Value::Null) {
4781                all_non_null = false;
4782            } else {
4783                all_null = false;
4784            }
4785        }
4786        return Ok(Value::Bool(if negated { all_non_null } else { all_null }));
4787    }
4788    let is_null = matches!(v, Value::Null);
4789    Ok(Value::Bool(if negated { !is_null } else { is_null }))
4790}
4791
4792pub fn eval_expr(
4793    expr: &Expr,
4794    row: &Row<'static>,
4795    ctx: &EvalContext<'_>,
4796) -> Result<Value<'static>, EvalError> {
4797    // v7.38 (read01 P3.25) — guard against a native stack overflow on a
4798    // pathologically nested expression (`a AND a AND … ` × thousands): the
4799    // recursion base is seeded on the outermost call, and once a deeper
4800    // call has consumed more than the budget we return an error the way
4801    // PG's check_stack_depth() does, rather than aborting the process.
4802    let sp = eval_stack_ptr();
4803    let base = ctx.recursion_base.get();
4804    if base == 0 {
4805        ctx.recursion_base.set(sp);
4806    } else if base.saturating_sub(sp) > MAX_EVAL_STACK_BYTES {
4807        return Err(EvalError::StackDepthExceeded);
4808    }
4809    match expr {
4810        Expr::AggregateOrdered { .. } => Err(EvalError::TypeMismatch {
4811            detail: "aggregate ORDER BY is only valid inside an aggregating SELECT".into(),
4812        }),
4813        // A named argument is only meaningful inside a call, where the callee's
4814        // parameter names give it a slot. Anywhere else it is a syntax error,
4815        // and saying so beats silently evaluating it as if the name were absent.
4816        Expr::NamedArg { name, .. } => Err(EvalError::TypeMismatch {
4817            detail: alloc::format!("named argument \"{name}\" is only valid in a function call"),
4818        }),
4819        Expr::Literal(l) => Ok(literal_to_value(l)),
4820        Expr::Column(c) => resolve_column(c, row, ctx),
4821        Expr::Placeholder(n) => {
4822            let idx = usize::from(*n).saturating_sub(1);
4823            ctx.params
4824                .get(idx)
4825                .cloned()
4826                .ok_or_else(|| EvalError::PlaceholderOutOfRange {
4827                    n: *n,
4828                    bound: u16::try_from(ctx.params.len()).unwrap_or(u16::MAX),
4829                })
4830        }
4831        // v7.39 (round 620) — an unadorned string literal carries PG's
4832        // `unknown` type, and a boolean connective is a context that resolves
4833        // it TO boolean. `'true' AND true`, `'f' OR false` and `NOT 'a'` are
4834        // answered by PG (`t`, `f`, and the input-syntax error respectively)
4835        // and were all refused here with `argument of … must be type boolean,
4836        // not type text` — the message PG reserves for an operand that really
4837        // IS text (`''::TEXT AND true`), which stays refused. Out-of-line and
4838        // behind a literal-shaped guard: this is the recursive frame the
4839        // 768 KiB stack budget is tuned against.
4840        Expr::Unary {
4841            op: spg_sql::ast::UnOp::Not,
4842            expr,
4843        } if !ctx.mysql_dialect && is_unknown_string_literal(expr) => apply_unary(
4844            spg_sql::ast::UnOp::Not,
4845            coerce_unknown_literal_to_bool(expr)?,
4846        ),
4847        Expr::Unary { op, expr } => {
4848            let v = eval_expr(expr, row, ctx)?;
4849            // The MySQL-specific unary readings (NOT any truth value, `-`/`~`
4850            // on a string, `~` unsigned) live out-of-line: `eval_expr` is the
4851            // recursive frame the 768 KiB stack-depth budget is tuned
4852            // against, and locals added here cost one nesting level each (the
4853            // round-305 / round-383 frame cliff).
4854            if ctx.mysql_dialect {
4855                if let Some(r) = mysql_unary_arm(*op, &v) {
4856                    return r;
4857                }
4858            }
4859            apply_unary(*op, v)
4860        }
4861        // v7.39 (round 346, M1) — MariaDB reads both sides of AND / OR as
4862        // truth values (`1 AND 2` is 1, measured). apply_binary has no
4863        // dialect, so the coercion happens here, where it does. The body
4864        // is out-of-line: `eval_expr` is the recursive frame the 768 KiB
4865        // stack-depth budget is tuned against, and locals added here cost
4866        // one nesting level each (the round-305 frame cliff).
4867        Expr::Binary { lhs, op, rhs }
4868            if ctx.mysql_dialect && matches!(op, BinOp::And | BinOp::Or | BinOp::LogicalXor) =>
4869        {
4870            eval_mysql_connective(lhs, *op, rhs, row, ctx)
4871        }
4872        // v7.39 (round 620/621) — the unknown-literal resolution and the
4873        // short circuit, both out of line. Placed AFTER the MySQL arm so the
4874        // dialect keeps its own reading of these connectives.
4875        Expr::Binary { lhs, op, rhs } if matches!(op, BinOp::And | BinOp::Or) => {
4876            eval_connective(lhs, *op, rhs, row, ctx)
4877        }
4878        Expr::Binary { lhs, op, rhs } => {
4879            // v7.32 (P4 borrow channel) — comparison fast path. A pure
4880            // comparison op only reads its operands and returns Bool,
4881            // and for non-NUMERIC / non-INTERVAL / non-CI-collation
4882            // operands `apply_binary` IS just the NULL-3VL check plus
4883            // the ref-based `compare` (NUMERIC routes through fixed-
4884            // point `apply_binary_numeric`; INTERVAL through
4885            // `apply_binary_interval`; CI columns fold). So read the
4886            // operands borrowed — a column cell is no longer cloned
4887            // just to compare it (`WHERE thread_id != ''` alone cloned
4888            // one Text cell per scanned row). Anything that needs the
4889            // owned path falls through unchanged.
4890            if matches!(
4891                op,
4892                BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq
4893            ) {
4894                let lc = eval_expr_cow(lhs, row, ctx)?;
4895                let rc = eval_expr_cow(rhs, row, ctx)?;
4896                // v7.39 (enum order knife) — enum-typed operands compare by
4897                // member order, not label text. Cold unless both sides are
4898                // Text and the catalog has enum types at all.
4899                if matches!(lc.as_ref(), Value::Text(_)) && matches!(rc.as_ref(), Value::Text(_)) {
4900                    if let Some(r) = enum_compare_hook(*op, lhs, rhs, lc.as_ref(), rc.as_ref(), ctx)
4901                    {
4902                        return r;
4903                    }
4904                    // v7.39 (round 693) — and the collation hook, under the
4905                    // same Text/Text gate for the same reason.
4906                    if let Some(r) =
4907                        collate_compare_hook(*op, lhs, rhs, lc.as_ref(), rc.as_ref(), ctx)
4908                    {
4909                        return r;
4910                    }
4911                }
4912                // v7.39 (round 351, M11) — the three conditions fold into
4913                // ONE call. Adding a fourth as another `||` here tipped the
4914                // 768 KiB stack guard on its own (measured — this is the
4915                // hottest recursive frame there is); one call is cheaper
4916                // than the three it replaces.
4917                let owned_path = needs_owned_compare(lc.as_ref(), rc.as_ref(), lhs, rhs, ctx);
4918                if !owned_path {
4919                    if lc.as_ref().is_null() || rc.as_ref().is_null() {
4920                        return Ok(Value::Null);
4921                    }
4922                    return compare(*op, lc.as_ref(), rc.as_ref()).map_err(|e| {
4923                        unknown_literal_cmp_error(e, lhs, rhs, lc.as_ref(), rc.as_ref())
4924                    });
4925                }
4926                let (l, r) = collation_fold_for_compare(
4927                    *op,
4928                    lhs,
4929                    rhs,
4930                    lc.into_owned(),
4931                    rc.into_owned(),
4932                    ctx,
4933                );
4934                // The owned call consumes the values; the rewrite needs the
4935                // literal's text and the other side's type only on the ERROR
4936                // path, so capture those two up front — the capture itself is
4937                // gated on the cheap expr test, so a comparison with no
4938                // unknown literal pays one branch.
4939                let probe = (is_unknown_string_literal(lhs) || is_unknown_string_literal(rhs))
4940                    .then(|| (l.clone(), r.clone()));
4941                return apply_binary_in(*op, l, r, ctx.mysql_dialect).map_err(|e| match &probe {
4942                    Some((pl, pr)) => unknown_literal_cmp_error(e, lhs, rhs, pl, pr),
4943                    None => e,
4944                });
4945            }
4946            let l = eval_expr(lhs, row, ctx)?;
4947            let r = eval_expr(rhs, row, ctx)?;
4948            // v7.17.0 Phase 2.5 — collation-aware text comparison.
4949            // When either operand of a comparison op references a
4950            // column declared `COLLATE "case_insensitive"` (or any
4951            // MySQL `_ci` collation), case-fold both sides before
4952            // the byte-wise compare so `WHERE name = 'foo'` matches
4953            // stored `'Foo'`. Non-Text values fall straight through
4954            // — the helper is a no-op outside Text-Text equality
4955            // and inequality.
4956            let (l, r) = collation_fold_for_compare(*op, lhs, rhs, l, r, ctx);
4957            // v7.39 (GUC knife 4) — `date/interval/float || text` textifies
4958            // through the out-functions, which honour the session render
4959            // style. Pre-render the style-sensitive operand here (the
4960            // orthodox home is an implicit-cast node at type resolution;
4961            // until then this keeps apply_binary style-free). Default
4962            // style short-circuits — text_concat's own value_to_text
4963            // produces the identical bytes.
4964            if matches!(op, spg_sql::ast::BinOp::Concat)
4965                && ctx.render_style != format::RenderStyle::default()
4966            {
4967                let styled = |v: Value<'static>| -> Value<'static> {
4968                    match &v {
4969                        Value::Date(_)
4970                        | Value::Timestamp(_)
4971                        | Value::Interval { .. }
4972                        | Value::Float(_)
4973                        | Value::Real(_) => {
4974                            Value::text(values::value_to_text_styled(&v, &ctx.render_style))
4975                        }
4976                        _ => v,
4977                    }
4978                };
4979                let (sl, sr) = (styled(l), styled(r));
4980                return apply_binary(*op, sl, sr);
4981            }
4982            apply_binary_mysql_unsigned(*op, lhs, rhs, l, r, ctx)
4983        }
4984        Expr::Cast { expr, target } => eval_cast_arm(expr, target, row, ctx),
4985        Expr::FieldAccess { base, field } => eval_field_access_arm(base, field, row, ctx),
4986        Expr::IsNull { expr, negated } => eval_is_null_arm(expr, *negated, row, ctx),
4987        // v7.39 (round 328, V45) — `x IS [NOT] TRUE | FALSE | UNKNOWN`.
4988        // Out-of-line like its IS NULL neighbour: an inline body here
4989        // grows every frame of the recursive evaluator, which is what
4990        // tipped the 512KB depth guard in round 305.
4991        Expr::BoolTest {
4992            expr,
4993            value,
4994            negated,
4995        } => eval_bool_test_arm(expr, *value, *negated, row, ctx),
4996        Expr::FunctionCall { name, args } => eval_function_call_arm(name, args, row, ctx),
4997        // v7.39 (read01 round 100) — VARIADIC is spliced into its enclosing
4998        // call before the args are evaluated (see eval_function_call_arm); a
4999        // bare one reaching here was written outside a function call.
5000        Expr::Variadic(_) => Err(EvalError::TypeMismatch {
5001            detail: "VARIADIC is only valid as a function-call argument".into(),
5002        }),
5003        Expr::Like {
5004            expr,
5005            pattern,
5006            negated,
5007            case_insensitive,
5008        } => eval_like_arm(expr, pattern, *negated, *case_insensitive, row, ctx),
5009        Expr::Extract { field, source } => eval_extract_arm(field, source, row, ctx),
5010        // v4.10: subquery nodes should have been resolved into
5011        // Literal / InList nodes by Engine::resolve_select_subqueries
5012        // before the row loop. Anything reaching here is a bug.
5013        Expr::ScalarSubquery(_)
5014        | Expr::Exists { .. }
5015        | Expr::InSubquery { .. }
5016        | Expr::RowInSubquery { .. }
5017        | Expr::RowCmpSubquery { .. } => Err(EvalError::TypeMismatch {
5018            detail: "subquery reached row eval — engine resolver bug".into(),
5019        }),
5020        // v7.30.2 (mailrs round-25) — flat `expr [NOT] IN (a, b, …)`.
5021        // Iterative scan with PG three-valued logic: TRUE on the first
5022        // Eq match; if nothing matched, NULL when the needle is NULL or
5023        // any comparison was NULL; FALSE otherwise. Empty list (only
5024        // reachable via an empty subquery result) is FALSE / TRUE even
5025        // for a NULL needle — no comparison ever happens.
5026        Expr::InList {
5027            expr,
5028            list,
5029            negated,
5030        } => eval_in_list_arm(expr, list, *negated, row, ctx),
5031        // v4.12: window functions should have been rewritten into
5032        // synthetic __win_N column references by
5033        // exec_select_with_window before row eval. Anything
5034        // reaching here is similarly a bug.
5035        Expr::WindowFunction { .. } => Err(EvalError::TypeMismatch {
5036            detail: "window function reached row eval — engine rewrite bug".into(),
5037        }),
5038        // v7.10.10 — `ARRAY[expr, expr, …]` constructor.
5039        // v7.11.13 — element-type detection: all integers →
5040        // IntArray (or BigIntArray when widening), any Text →
5041        // TextArray. Non-TEXT non-integer elements (Bool, Float)
5042        // stringify into TextArray as the safe default.
5043        Expr::Array(items) => eval_array_arm(items, row, ctx),
5044        // v7.10.12 — `arr[i]` PG-style 1-based indexing.
5045        // Out-of-range indices (including i ≤ 0) return NULL.
5046        Expr::ArraySubscript { .. } => eval_array_subscript_arm(expr, row, ctx),
5047        // Array slice `arr[lo:hi]` — PG 1-based, both ends
5048        // inclusive, out-of-range bounds clamp, missing bounds
5049        // extend to the array's ends. Result keeps the element
5050        // type; an empty window yields an empty array.
5051        Expr::ArraySlice { target, lo, hi } => eval_array_slice_arm(target, lo, hi, row, ctx),
5052        // v7.10.12 — `x op ANY(arr)` / `x op ALL(arr)`. PG
5053        // 3VL: ANY → true if any element compares-true; NULL if
5054        // no true but some NULL; false otherwise. ALL: false if
5055        // any compares-false; NULL if no false but some NULL;
5056        // true otherwise.
5057        Expr::AnyAll {
5058            expr,
5059            op,
5060            array,
5061            is_any,
5062        } => eval_any_all_arm(expr, op, array, *is_any, row, ctx),
5063        // v7.13.0 — CASE WHEN … END (mailrs round-5 G9).
5064        // Short-circuit on the first matching branch. Searched form
5065        // (operand=None) treats each branch's WHEN as a Bool
5066        // predicate. Simple form (operand=Some) compares with =.
5067        // ELSE on no match; NULL if no ELSE.
5068        Expr::Case {
5069            operand,
5070            branches,
5071            else_branch,
5072        } => eval_case_arm(operand, branches, else_branch, row, ctx),
5073    }
5074}
5075
5076/// v7.10.10 — best-effort text rendering for non-TEXT array
5077/// elements (numbers, bools, etc.). The PG rule is that
5078/// `ARRAY[1, 2]` is `int[]`, but SPG's v7.10 only models TEXT[],
5079/// so we widen by stringifying. NUMERIC formatting goes through
5080/// the existing canonical helpers to stay consistent with
5081/// `format_numeric` / `format_date` etc.
5082/// v7.37 D.1 — the COALESCE result-type hint: a sibling branch's explicit
5083/// cast target (`NULL::time`, `col::time`), unless it is Text (Text carries no
5084/// coercion). Returns the first non-Text `CastTarget` found, mirroring PG's
5085/// left-to-right common-type resolution for the common single-typed-branch case.
5086fn coalesce_type_hint(e: &Expr) -> Option<CastTarget> {
5087    match e {
5088        Expr::Cast { target, .. } if !matches!(target, CastTarget::Text) => Some(target.clone()),
5089        _ => None,
5090    }
5091}
5092
5093/// v7.38 (read01) — widen `v` to the already-resolved common type `common`
5094/// of a `CASE`/`COALESCE`/`GREATEST`/`LEAST`/`NULLIF` result, so the value's
5095/// type matches the one PG reports and downstream operators (e.g. `/`) see
5096/// the widened type (integer division vs numeric division). Only widens;
5097/// anything already at the common type, or that fails to coerce, is returned
5098/// untouched (this must never turn a working expression into an error).
5099/// NUMERIC is scale-preserving: an existing exact-numeric keeps its own scale
5100/// (PG renders `COALESCE(1.50, 2)` as `1.50`); only integers promote, to
5101/// scale 0.
5102pub(crate) fn widen_value_to(v: Value<'static>, common: spg_storage::DataType) -> Value<'static> {
5103    use spg_storage::DataType as DT;
5104    // Only widen numeric- and temporal-category results: these are the ones
5105    // whose type actually changes a downstream value (integer vs numeric
5106    // division, date vs timestamp). Widening a string result (varchar ∪ text)
5107    // would only relabel the type while risking a spurious length-limit error
5108    // when coercing into a modelled-length varchar/char, so leave it as-is.
5109    if !matches!(
5110        common,
5111        DT::SmallInt
5112            | DT::Int
5113            | DT::BigInt
5114            | DT::Numeric { .. }
5115            // v7.39 (round 649) — `real` was absent here too, so even once
5116            // `common_type` learned to rank it, the value was handed back
5117            // unwidened: `coalesce(1::int, 1::real)` stayed integer where
5118            // PG says real. Two lists, one ladder — the gap had to be
5119            // closed in both.
5120            | DT::Real
5121            | DT::Float
5122            | DT::Date
5123            | DT::Time
5124            | DT::Timestamp
5125            | DT::Timestamptz
5126    ) {
5127        return v;
5128    }
5129    if matches!(v, Value::Null) {
5130        return v;
5131    }
5132    if v.data_type() == Some(common) {
5133        return v;
5134    }
5135    if matches!(common, spg_storage::DataType::Numeric { .. })
5136        && matches!(v, Value::Numeric { .. } | Value::NumericBig(_))
5137    {
5138        return v;
5139    }
5140    let target = if matches!(common, spg_storage::DataType::Numeric { .. }) {
5141        spg_storage::DataType::Numeric {
5142            precision: 0,
5143            scale: 0,
5144        }
5145    } else {
5146        common
5147    };
5148    match crate::conversions::coerce_value(v.clone(), target, "", 0) {
5149        Ok(cv) => cv,
5150        Err(_) => v,
5151    }
5152}
5153
5154/// Widen `v` to the PG common type of the sibling `types`, or leave it as-is
5155/// when the types don't resolve to a single widening type. See
5156/// [`widen_value_to`] and [`crate::describe::common_type`].
5157pub(crate) fn widen_to_common(
5158    v: Value<'static>,
5159    types: &[spg_storage::DataType],
5160) -> Value<'static> {
5161    match crate::describe::common_type(types) {
5162        Some(common) => widen_value_to(v, common),
5163        None => v,
5164    }
5165}
5166
5167pub(crate) fn value_to_text_for_array(v: &Value, style: &format::RenderStyle) -> String {
5168    match v {
5169        Value::Text(s) | Value::Json(s) => s.to_string(),
5170        Value::Int(n) => n.to_string(),
5171        Value::BigInt(n) => n.to_string(),
5172        Value::SmallInt(n) => n.to_string(),
5173        // PG renders booleans in array external form as `t` / `f`
5174        // (the bool type's output function), not `true` / `false`.
5175        Value::Bool(b) => {
5176            if *b {
5177                "t".into()
5178            } else {
5179                "f".into()
5180            }
5181        }
5182        Value::Float(x) => format::format_float_styled(*x, style),
5183        Value::Real(x) => format::format_real_styled(*x, style),
5184        Value::Date(d) => format::format_date_styled(*d, style),
5185        Value::Timestamp(t) => format::format_timestamp_styled(*t, style),
5186        Value::Numeric {
5187            scaled,
5188            scale,
5189            kind,
5190        } => format_numeric_kind(*kind, *scaled, *scale),
5191        // v7.39 — everything else renders its canonical PG text (this
5192        // Debug fallback is how `ARRAY['\xff'::bytea]` printed
5193        // `{Bytes([255])}` on the wire).
5194        _ => values::value_to_text_styled(v, style),
5195    }
5196}
5197
5198/// SQL `LIKE` matcher. Wildcards are `%` (any run, possibly empty) and `_`
5199/// (exactly one char). `\` escapes the next pattern char so `\%` matches a
5200/// literal `%`. Matches the whole input — no implicit anchoring needed
5201/// since SQL `LIKE` is always full-string. Errs on a trailing unpaired
5202/// escape the matcher actually reaches with text left (PG's lazy 22025).
5203fn like_match(text: &str, pattern: &str) -> Result<bool, EvalError> {
5204    let pat: Vec<char> = pattern.chars().collect();
5205    like_match_str(text, &pat, 0)
5206}
5207
5208/// v7.37.16 — pg_typeof spelling for a STATIC column type (the
5209/// NULL-cell fallback; the value-level table is `pg_typeof_name`).
5210/// TEXT maps to None because a NULL literal describes as TEXT — the
5211/// unknown stand-in — and pg_typeof(NULL) must stay "unknown"; every
5212/// type not listed also returns None (caller keeps the value answer).
5213pub(crate) fn pg_typeof_name_for_datatype(t: spg_storage::DataType) -> Option<&'static str> {
5214    use spg_storage::DataType as D;
5215    Some(match t {
5216        D::SmallInt => "smallint",
5217        D::Int => "integer",
5218        D::BigInt => "bigint",
5219        D::Float => "double precision",
5220        D::Real => "real",
5221        D::Numeric { .. } => "numeric",
5222        D::Bool => "boolean",
5223        D::Date => "date",
5224        D::Time => "time without time zone",
5225        D::Timestamp => "timestamp without time zone",
5226        D::Timestamptz => "timestamp with time zone",
5227        D::Name => "name",
5228        D::Xid => "xid",
5229        D::Xid8 => "xid8",
5230        D::Oid => "oid",
5231        // v7.39 (round 694) — and its array, for the same reason.
5232        D::OidArray => "oid[]",
5233        D::Uuid => "uuid",
5234        D::Interval => "interval",
5235        // v7.39 (round 871) — the rest of what a NULL cast can be
5236        // annotated with. This table decided which types survived
5237        // `pg_typeof(NULL::t)`: the twenty above answered, everything
5238        // else fell to `_ => None` and reported `unknown`. That reads
5239        // as a NULL problem and is not one — `NULL::uuid` was right all
5240        // along while `NULL::text` was wrong, because one was listed
5241        // and the other was not.
5242        //
5243        // Names are PG18's own, taken from running `pg_typeof` there
5244        // rather than from memory: `bit varying` not `varbit`, `bit`
5245        // for any width, `character varying`, `"char"` quoted.
5246        D::Text => "text",
5247        D::Multirange(k) => match k {
5248            spg_storage::RangeKind::Int4 => "int4multirange",
5249            spg_storage::RangeKind::Int8 => "int8multirange",
5250            spg_storage::RangeKind::Num => "nummultirange",
5251            spg_storage::RangeKind::Ts => "tsmultirange",
5252            spg_storage::RangeKind::TsTz => "tstzmultirange",
5253            spg_storage::RangeKind::Date => "datemultirange",
5254        },
5255        D::Varchar(_) => "character varying",
5256        // PG names `char(n)` "character"; the one-byte internal type
5257        // spelled `"char"` is a DIFFERENT type there, and SPG maps both
5258        // onto `Char(u32)` — so this arm must answer for the declared
5259        // one. Round 871's first attempt said `"char"` here and would
5260        // have reported `char(5)` as the internal type.
5261        D::Char(_) => "character",
5262        D::Json => "json",
5263        D::Jsonb => "jsonb",
5264        D::Bytes => "bytea",
5265        D::Inet => "inet",
5266        D::Cidr => "cidr",
5267        D::Macaddr => "macaddr",
5268        D::Macaddr8 => "macaddr8",
5269        D::Bit(_) => "bit",
5270        D::BitVarying(_) => "bit varying",
5271        D::Xml => "xml",
5272        D::Money => "money",
5273        D::Point => "point",
5274        D::Lseg => "lseg",
5275        D::Path => "path",
5276        D::PgBox => "box",
5277        D::Polygon => "polygon",
5278        D::Line => "line",
5279        D::Circle => "circle",
5280        D::TextArray => "text[]",
5281        D::IntArray => "integer[]",
5282        D::BigIntArray => "bigint[]",
5283        D::SmallIntArray => "smallint[]",
5284        D::FloatArray => "double precision[]",
5285        D::NumericArray => "numeric[]",
5286        D::BoolArray => "boolean[]",
5287        D::DateArray => "date[]",
5288        D::TimestampArray => "timestamp without time zone[]",
5289        D::TimestamptzArray => "timestamp with time zone[]",
5290        D::UuidArray => "uuid[]",
5291        D::JsonArray => "json[]",
5292        D::JsonbArray => "jsonb[]",
5293        D::BytesArray => "bytea[]",
5294        D::VarcharArray => "character varying[]",
5295        D::CharArray => "\"char\"[]",
5296        D::IntervalArray => "interval[]",
5297        _ => return None,
5298    })
5299}
5300
5301/// v7.37.16 — zero-allocation LIKE core: the text side walks a `&str`
5302/// cursor (char-semantic — `_` consumes one CHARACTER, `%` backtracks
5303/// only at char boundaries) instead of collecting a `Vec<char>` per
5304/// call. The old per-row collect was ~50 ns/row of pure allocator
5305/// traffic on a 50 k-row `WHERE s LIKE '%…%'` scan (the heavy.rs
5306/// like_filter 2.8× loss); the pattern side stays a compile-once
5307/// `&[char]` (see `Step::Like`).
5308pub(crate) fn like_match_str(text: &str, pat: &[char], mut pi: usize) -> Result<bool, EvalError> {
5309    let mut t = text;
5310    while pi < pat.len() {
5311        match pat[pi] {
5312            '%' => {
5313                // Collapse consecutive `%` and try every possible split.
5314                while pi < pat.len() && pat[pi] == '%' {
5315                    pi += 1;
5316                }
5317                if pi == pat.len() {
5318                    return Ok(true);
5319                }
5320                let mut rest = t;
5321                loop {
5322                    if like_match_str(rest, pat, pi)? {
5323                        return Ok(true);
5324                    }
5325                    match rest.chars().next() {
5326                        Some(c) => rest = &rest[c.len_utf8()..],
5327                        None => return Ok(false),
5328                    }
5329                }
5330            }
5331            '_' => match t.chars().next() {
5332                Some(c) => {
5333                    t = &t[c.len_utf8()..];
5334                    pi += 1;
5335                }
5336                None => return Ok(false),
5337            },
5338            // v7.39 (round 144, like_match.c) — a trailing unpaired escape is
5339            // PG's 22025 error, but LAZILY: only when the matcher reaches it
5340            // with text left. A branch where the text is already exhausted
5341            // returns false without ever "seeing" the trailing escape
5342            // ('x' LIKE 'x\' is false; 'xy' LIKE 'x\' errors).
5343            '\\' if pi + 1 >= pat.len() => {
5344                if t.is_empty() {
5345                    return Ok(false);
5346                }
5347                return Err(EvalError::TypeMismatch {
5348                    detail: "LIKE pattern must not end with escape character".into(),
5349                });
5350            }
5351            '\\' => {
5352                let want = pat[pi + 1];
5353                match t.chars().next() {
5354                    Some(c) if c == want => {
5355                        t = &t[c.len_utf8()..];
5356                        pi += 2;
5357                    }
5358                    _ => return Ok(false),
5359                }
5360            }
5361            c => match t.chars().next() {
5362                Some(tc) if tc == c => {
5363                    t = &t[c.len_utf8()..];
5364                    pi += 1;
5365                }
5366                _ => return Ok(false),
5367            },
5368        }
5369    }
5370    Ok(t.is_empty())
5371}
5372
5373/// v7.24 (round-15) — `string_to_array(text, delimiter)`.
5374fn fn_string_to_array(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
5375    // v7.37.17 (17.6 siblings) — the 3-arg PG form adds
5376    // `null_string`: elements equal to it become SQL NULL.
5377    let (text_arg, delim_arg, null_arg) = match args {
5378        [t, d] => (t, d, None),
5379        [t, d, n] => (t, d, Some(n)),
5380        _ => {
5381            return Err(EvalError::TypeMismatch {
5382                detail: alloc::format!(
5383                    "string_to_array expects 2 or 3 arguments, got {}",
5384                    args.len()
5385                ),
5386            });
5387        }
5388    };
5389    let null_string: Option<&str> = match null_arg {
5390        None | Some(Value::Null) => None,
5391        Some(Value::Text(s)) => Some(s.as_ref()),
5392        Some(other) => {
5393            return Err(EvalError::TypeMismatch {
5394                detail: alloc::format!(
5395                    "string_to_array null_string must be text, got {}",
5396                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
5397                ),
5398            });
5399        }
5400    };
5401    let text = match text_arg {
5402        Value::Null => return Ok(Value::Null),
5403        Value::Text(t) => t,
5404        other => {
5405            return Err(EvalError::TypeMismatch {
5406                detail: alloc::format!(
5407                    "string_to_array expects text, got {}",
5408                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
5409                ),
5410            });
5411        }
5412    };
5413    // PG (9.1+): empty input → empty array, regardless of delimiter.
5414    if text.is_empty() {
5415        return Ok(Value::TextArray(Vec::new()));
5416    }
5417    let nullify = |p: String| -> Option<String> {
5418        if null_string == Some(p.as_str()) {
5419            None
5420        } else {
5421            Some(p)
5422        }
5423    };
5424    let parts: Vec<Option<String>> = match delim_arg {
5425        // NULL delimiter → one element per character.
5426        Value::Null => text.chars().map(|c| nullify(c.to_string())).collect(),
5427        Value::Text(d) if d.is_empty() => alloc::vec![nullify(text.to_string())],
5428        Value::Text(d) => text
5429            .split(d.as_ref())
5430            .map(|p| nullify(p.to_string()))
5431            .collect(),
5432        other => {
5433            return Err(EvalError::TypeMismatch {
5434                detail: alloc::format!(
5435                    "string_to_array delimiter must be text, got {}",
5436                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
5437                ),
5438            });
5439        }
5440    };
5441    Ok(Value::TextArray(parts))
5442}
5443
5444/// v6.4.3 — `error_on_null(v)`. Returns `v` unchanged if non-NULL;
5445/// errors otherwise. Convenience to assert NOT NULL inside an
5446/// expression without wrapping it in COALESCE + raise hacks.
5447fn error_on_null(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
5448    if args.len() != 1 {
5449        return Err(EvalError::TypeMismatch {
5450            detail: format!("error_on_null() takes 1 arg, got {}", args.len()),
5451        });
5452    }
5453    if matches!(args[0], Value::Null) {
5454        return Err(EvalError::TypeMismatch {
5455            detail: "error_on_null(): argument is NULL".into(),
5456        });
5457    }
5458    Ok(args[0].clone().into_owned())
5459}
5460
5461/// Helper: coerce a Value to an Option<String> for regex args. NULL
5462/// propagates as None (caller short-circuits to Value::Null).
5463fn text_arg(v: &Value) -> Result<Option<String>, EvalError> {
5464    match v {
5465        Value::Text(s) => Ok(Some(s.to_string())),
5466        Value::Null => Ok(None),
5467        other => Err(EvalError::TypeMismatch {
5468            detail: alloc::format!(
5469                "regex function expects TEXT arg, got {}",
5470                crate::conversions::pg_type_name_for_error_opt(other.data_type())
5471            ),
5472        }),
5473    }
5474}
5475
5476// Month-name tables shared by the date formatters in `eval::strings`
5477// (`date_format_mysql`) and `eval::datetime` via `use super::`. Kept in
5478// `eval.rs` alongside `civil_from_days` so the calendar primitives live
5479// in one place.
5480const MONTH_FULL: [&str; 12] = [
5481    "January",
5482    "February",
5483    "March",
5484    "April",
5485    "May",
5486    "June",
5487    "July",
5488    "August",
5489    "September",
5490    "October",
5491    "November",
5492    "December",
5493];
5494const MONTH_ABBR: [&str; 12] = [
5495    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
5496];
5497
5498/// Howard Hinnant's `civil_from_days` — converts days since the Unix
5499/// epoch back to a proleptic-Gregorian (year, month, day) triple. Stays
5500/// in `eval.rs` (shared with the date SQL functions here and with
5501/// `eval::strings`); the inverse `days_from_civil` lives in
5502/// `eval::format`. Both keep the engine off `std` time facilities.
5503#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5504fn civil_from_days(days: i32) -> (i32, u32, u32) {
5505    let z = i64::from(days) + 719_468;
5506    let era = z.div_euclid(146_097);
5507    // doe ∈ [0, 146_097); fits in u32 with room to spare. Same for
5508    // every other quantity below — `as u32` truncations are safe by
5509    // construction.
5510    let doe = (z - era * 146_097) as u32;
5511    let yoe = (doe.saturating_sub(doe / 1460) + doe / 36524 - doe / 146_096) / 365;
5512    let y_base = i64::from(yoe) + era * 400;
5513    let doy = doe.saturating_sub(365 * yoe + yoe / 4 - yoe / 100);
5514    let mp = (5 * doy + 2) / 153;
5515    let d = doy.saturating_sub((153 * mp + 2) / 5) + 1;
5516    let m = if mp < 10 { mp + 3 } else { mp - 9 };
5517    let y = if m <= 2 { y_base + 1 } else { y_base };
5518    (y as i32, m, d)
5519}
5520
5521/// Add `months` (signed) to a `(year, month, day)` triple using PG's
5522/// clamp-to-last-day rule (so `'2024-01-31' + 1 month` → `'2024-02-29'`).
5523fn add_months_to_civil(y: i32, m: u32, d: u32, months: i32) -> (i32, u32, u32) {
5524    let total_months = i64::from(y) * 12 + i64::from(m) - 1 + i64::from(months);
5525    let new_year = i32::try_from(total_months.div_euclid(12)).unwrap_or(i32::MAX);
5526    let new_month_zero = total_months.rem_euclid(12);
5527    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
5528    let new_month = (new_month_zero as u32) + 1;
5529    let max_day = days_in_month(new_year, new_month);
5530    (new_year, new_month, d.min(max_day))
5531}
5532
5533const fn days_in_month(y: i32, m: u32) -> u32 {
5534    match m {
5535        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
5536        2 => {
5537            // Proleptic Gregorian leap rule.
5538            if y.rem_euclid(4) == 0 && (y.rem_euclid(100) != 0 || y.rem_euclid(400) == 0) {
5539                29
5540            } else {
5541                28
5542            }
5543        }
5544        // 4 / 6 / 9 / 11 plus any out-of-range month (callers normalise
5545        // first, but be defensive) get the 30-day fallback.
5546        _ => 30,
5547    }
5548}
5549
5550pub(crate) fn literal_to_value(l: &Literal) -> Value<'static> {
5551    match l {
5552        Literal::Integer(n) => {
5553            if let Ok(small) = i32::try_from(*n) {
5554                Value::Int(small)
5555            } else {
5556                Value::BigInt(*n)
5557            }
5558        }
5559        Literal::Float(x) => Value::Float(*x),
5560        Literal::Numeric { unscaled, scale } => Value::Numeric {
5561            scaled: *unscaled,
5562            scale: *scale,
5563            kind: spg_storage::NumericKind::Finite,
5564        },
5565        Literal::NumericBig(s) => crate::conversions::big_literal_to_value(s),
5566        Literal::String(s) => Value::text(s.clone()),
5567        Literal::Vector(v) => Value::vector(v.clone()),
5568        Literal::TextArray(items) => Value::TextArray(items.clone()),
5569        Literal::IntArray(items) => Value::IntArray(items.clone()),
5570        Literal::BigIntArray(items) => Value::BigIntArray(items.clone()),
5571        Literal::Bool(b) => Value::Bool(*b),
5572        Literal::Null => Value::Null,
5573        Literal::Interval {
5574            months,
5575            days,
5576            micros,
5577            ..
5578        } => Value::Interval {
5579            months: *months,
5580            days: *days,
5581            micros: *micros,
5582        },
5583    }
5584}
5585
5586impl crate::Engine {
5587    /// v7.39 (read01 round 63) — run a user function whose body has its own
5588    /// FROM. The arguments are substituted into the body as literals and the
5589    /// SELECT goes through the REAL executor, so it sees exactly the rows a
5590    /// hand-written query would: the row-header visibility filter applies, and
5591    /// under in-place MVCC a dead row stays dead.
5592    ///
5593    /// PG returns the FIRST row of a scalar SQL function's body (and NULL when
5594    /// it returns none).
5595    pub(crate) fn run_user_fn_query(
5596        &self,
5597        def: &spg_storage::FunctionDef,
5598        stmt: &spg_sql::ast::SelectStatement,
5599        arg_names: &[alloc::string::String],
5600        args: &spg_storage::Row<'static>,
5601        fn_depth: u16,
5602    ) -> Result<Value<'static>, EvalError> {
5603        const MAX_QUERY_FN_DEPTH: u16 = 8;
5604        if fn_depth >= MAX_QUERY_FN_DEPTH {
5605            return Err(EvalError::TypeMismatch {
5606                detail: alloc::format!(
5607                    "function {:?}: a body with its own FROM may nest at most {MAX_QUERY_FN_DEPTH} deep",
5608                    def.name
5609                ),
5610            });
5611        }
5612        // Bind the arguments — the same helper the set-returning path uses, so
5613        // both resolve an argument identically (a COLUMN of the body's own FROM
5614        // shadows a same-named argument, as in PG).
5615        let owned: alloc::vec::Vec<Value<'static>> =
5616            args.values.iter().map(|v| v.clone().into_owned()).collect();
5617        let bound =
5618            bind_user_fn_args(self.active_catalog(), stmt, arg_names, &owned).map_err(|e| {
5619                EvalError::TypeMismatch {
5620                    detail: alloc::format!("function {:?}: {e}", def.name),
5621                }
5622            })?;
5623
5624        // v7.39 (round 334, V55) — a SECURITY DEFINER body is authorised as
5625        // the function's OWNER. Measured on PG 18.4: a definer function
5626        // owned by `owner55` counts rows of a table `caller55` cannot read,
5627        // while the SECURITY INVOKER sibling is refused.
5628        let as_role = def.security_definer.then(|| def.owner.as_deref()).flatten();
5629        let out = self
5630            .exec_select_cancel_as(&bound, crate::CancelToken::none(), as_role)
5631            .map_err(|e| EvalError::TypeMismatch {
5632                detail: alloc::format!("function {:?}: {e}", def.name),
5633            })?;
5634        let crate::QueryResult::Rows { rows, .. } = out else {
5635            return Ok(Value::Null);
5636        };
5637        let Some(first) = rows.first() else {
5638            // No row: PG's scalar SQL function returns NULL.
5639            return Ok(Value::Null);
5640        };
5641        let v = first.values.first().cloned().unwrap_or(Value::Null);
5642        let declared = def.returns.trim();
5643        if declared.eq_ignore_ascii_case("VOID") {
5644            return Ok(Value::Null);
5645        }
5646        crate::eval::cast::cast_value(
5647            v.into_owned(),
5648            spg_sql::ast::CastTarget::Named(alloc::string::String::from(declared)),
5649        )
5650        .or_else(|_| Ok(Value::Null))
5651    }
5652}
5653
5654/// v7.39 (read01 round 65) — bind a call's arguments into a function body's
5655/// SELECT, as literals. Shared by the scalar path (round 63) and the
5656/// set-returning one, so both resolve an argument the same way — including the
5657/// rule that a COLUMN of the body's own FROM shadows a same-named argument.
5658pub(crate) fn bind_user_fn_args(
5659    cat: &spg_storage::Catalog,
5660    stmt: &spg_sql::ast::SelectStatement,
5661    arg_names: &[alloc::string::String],
5662    args: &[Value<'static>],
5663) -> Result<spg_sql::ast::SelectStatement, EvalError> {
5664    let mut bound = stmt.clone();
5665    let mut binds: alloc::collections::BTreeMap<alloc::string::String, spg_sql::ast::Expr> =
5666        alloc::collections::BTreeMap::new();
5667    for (i, name) in arg_names.iter().enumerate() {
5668        if name.is_empty() {
5669            continue;
5670        }
5671        let shadowed = body_from_tables(stmt).iter().any(|t| {
5672            cat.get(t).is_some_and(|tb| {
5673                tb.schema()
5674                    .columns
5675                    .iter()
5676                    .any(|c| c.name.eq_ignore_ascii_case(name))
5677            })
5678        });
5679        if shadowed {
5680            continue;
5681        }
5682        let v = args.get(i).cloned().unwrap_or(Value::Null);
5683        let lit =
5684            crate::substitute::value_to_literal_expr(v).map_err(|e| EvalError::TypeMismatch {
5685                detail: alloc::format!("argument {name} cannot be bound into the body: {e}"),
5686            })?;
5687        binds.insert(name.to_ascii_lowercase(), lit);
5688    }
5689    substitute_arg_refs_in_select(&mut bound, &binds);
5690    Ok(bound)
5691}
5692
5693/// The base tables a function body's FROM names — used to decide whether an
5694/// argument name is shadowed by a column of the same name.
5695fn body_from_tables(
5696    stmt: &spg_sql::ast::SelectStatement,
5697) -> alloc::vec::Vec<alloc::string::String> {
5698    let mut out = alloc::vec::Vec::new();
5699    if let Some(from) = &stmt.from {
5700        out.push(from.primary.name.clone());
5701        for j in &from.joins {
5702            out.push(j.table.name.clone());
5703        }
5704    }
5705    out
5706}
5707
5708/// Replace every bare reference to an argument name with its literal value.
5709fn substitute_arg_refs_in_select(
5710    stmt: &mut spg_sql::ast::SelectStatement,
5711    binds: &alloc::collections::BTreeMap<alloc::string::String, spg_sql::ast::Expr>,
5712) {
5713    use spg_sql::ast::{Expr, SelectItem};
5714    fn walk(e: &mut Expr, binds: &alloc::collections::BTreeMap<alloc::string::String, Expr>) {
5715        match e {
5716            Expr::Column(c) => {
5717                if c.qualifier.is_none()
5718                    && let Some(lit) = binds.get(&c.name.to_ascii_lowercase())
5719                {
5720                    *e = lit.clone();
5721                }
5722            }
5723            Expr::Binary { lhs, rhs, .. } => {
5724                walk(lhs, binds);
5725                walk(rhs, binds);
5726            }
5727            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } => walk(expr, binds),
5728            Expr::FunctionCall { args, .. } => args.iter_mut().for_each(|a| walk(a, binds)),
5729            Expr::Case {
5730                operand,
5731                branches,
5732                else_branch,
5733            } => {
5734                if let Some(o) = operand {
5735                    walk(o, binds);
5736                }
5737                for (c, v) in branches.iter_mut() {
5738                    walk(c, binds);
5739                    walk(v, binds);
5740                }
5741                if let Some(x) = else_branch {
5742                    walk(x, binds);
5743                }
5744            }
5745            Expr::InList { expr, list, .. } => {
5746                walk(expr, binds);
5747                list.iter_mut().for_each(|it| walk(it, binds));
5748            }
5749            Expr::AnyAll { expr, array, .. } => {
5750                walk(expr, binds);
5751                walk(array, binds);
5752            }
5753            Expr::Array(items) => items.iter_mut().for_each(|it| walk(it, binds)),
5754            Expr::ArraySubscript { target, index } => {
5755                walk(target, binds);
5756                walk(index, binds);
5757            }
5758            _ => {}
5759        }
5760    }
5761    for item in &mut stmt.items {
5762        if let SelectItem::Expr { expr, .. } = item {
5763            walk(expr, binds);
5764        }
5765    }
5766    if let Some(w) = &mut stmt.where_ {
5767        walk(w, binds);
5768    }
5769    if let Some(h) = &mut stmt.having {
5770        walk(h, binds);
5771    }
5772    if let Some(gs) = &mut stmt.group_by {
5773        gs.iter_mut().for_each(|g| walk(g, binds));
5774    }
5775    for o in &mut stmt.order_by {
5776        walk(&mut o.expr, binds);
5777    }
5778    if let Some(from) = &mut stmt.from {
5779        for j in &mut from.joins {
5780            if let Some(on) = &mut j.on {
5781                walk(on, binds);
5782            }
5783        }
5784    }
5785    // v7.39 (read01 round 69) — the UNION peers. A body like
5786    // `SELECT k UNION ALL SELECT k * 10` has its second half in `unions`, and
5787    // leaving it unsubstituted made the argument look like a missing column.
5788    for (_, peer) in &mut stmt.unions {
5789        substitute_arg_refs_in_select(peer, binds);
5790    }
5791    for cte in &mut stmt.ctes {
5792        if let Some(s) = cte.body.as_select_mut() {
5793            substitute_arg_refs_in_select(s, binds);
5794        }
5795    }
5796}
5797
5798impl crate::Engine {
5799    /// v7.39 (read01 round 64) — call a plpgsql function as a scalar. The body
5800    /// runs on the interpreter the DO block and triggers already use, with the
5801    /// arguments bound as locals; its `SELECT … INTO` and `FOR … IN SELECT`
5802    /// resolvers go through the READ path, so what the body sees is what a
5803    /// hand-written query would see (visibility filter and all).
5804    ///
5805    /// A body that writes is refused — the call arrives through expression
5806    /// evaluation, which holds the engine immutably. Refusing is the honest
5807    /// answer; silently dropping the write would be the worst one.
5808    pub(crate) fn call_plpgsql_scalar_fn(
5809        &self,
5810        def: &spg_storage::FunctionDef,
5811        arg_names: &[alloc::string::String],
5812        args: &spg_storage::Row<'static>,
5813    ) -> Result<Value<'static>, EvalError> {
5814        let block =
5815            spg_sql::parse_function_body(def.body.trim()).map_err(|e| EvalError::TypeMismatch {
5816                detail: alloc::format!("function {:?} body does not parse: {e}", def.name),
5817            })?;
5818        let mut locals: alloc::collections::BTreeMap<alloc::string::String, Value<'static>> =
5819            alloc::collections::BTreeMap::new();
5820        for (i, n) in arg_names.iter().enumerate() {
5821            if n.is_empty() {
5822                continue;
5823            }
5824            locals.insert(
5825                n.to_ascii_lowercase(),
5826                args.values.get(i).cloned().unwrap_or(Value::Null),
5827            );
5828        }
5829        let dts = self
5830            .session_param("default_text_search_config")
5831            .map(alloc::string::String::from);
5832
5833        let select_into = |stmt: &spg_sql::ast::Statement| -> Result<
5834            Value<'static>,
5835            crate::triggers::TriggerError,
5836        > {
5837            let spg_sql::ast::Statement::Select(s) = stmt else {
5838                return Err(crate::triggers::TriggerError::EvalFailed {
5839                    function: def.name.clone(),
5840                    cause: EvalError::TypeMismatch {
5841                        detail: "SELECT … INTO body must be a SELECT".into(),
5842                    },
5843                });
5844            };
5845            let r = self
5846                .exec_select_cancel(s, crate::CancelToken::none())
5847                .map_err(|e| crate::triggers::TriggerError::EvalFailed {
5848                    function: def.name.clone(),
5849                    cause: EvalError::TypeMismatch {
5850                        detail: alloc::format!("SELECT … INTO failed: {e}"),
5851                    },
5852                })?;
5853            match r {
5854                crate::QueryResult::Rows { rows, .. } => Ok(rows
5855                    .into_iter()
5856                    .next()
5857                    .and_then(|row| row.values.into_iter().next())
5858                    .unwrap_or(Value::Null)),
5859                _ => Ok(Value::Null),
5860            }
5861        };
5862        let for_query = |stmt: &spg_sql::ast::Statement| -> Result<
5863            (
5864                alloc::vec::Vec<alloc::string::String>,
5865                alloc::vec::Vec<alloc::vec::Vec<Value<'static>>>,
5866            ),
5867            crate::triggers::TriggerError,
5868        > {
5869            let spg_sql::ast::Statement::Select(s) = stmt else {
5870                return Err(crate::triggers::TriggerError::EvalFailed {
5871                    function: def.name.clone(),
5872                    cause: EvalError::TypeMismatch {
5873                        detail: "FOR … IN body must be a SELECT".into(),
5874                    },
5875                });
5876            };
5877            let r = self
5878                .exec_select_cancel(s, crate::CancelToken::none())
5879                .map_err(|e| crate::triggers::TriggerError::EvalFailed {
5880                    function: def.name.clone(),
5881                    cause: EvalError::TypeMismatch {
5882                        detail: alloc::format!("FOR … IN SELECT failed: {e}"),
5883                    },
5884                })?;
5885            match r {
5886                crate::QueryResult::Rows { columns, rows } => Ok((
5887                    columns.iter().map(|c| c.name.clone()).collect(),
5888                    rows.into_iter().map(|row| row.values).collect(),
5889                )),
5890                _ => Ok((alloc::vec::Vec::new(), alloc::vec::Vec::new())),
5891            }
5892        };
5893
5894        let out = crate::triggers::call_plpgsql_scalar(
5895            &def.name,
5896            &block,
5897            locals,
5898            dts.as_deref(),
5899            Some(&select_into),
5900            Some(&for_query),
5901            // A scalar call has no set to build; RETURN NEXT / RETURN QUERY are
5902            // errors here, as in PG.
5903            // (second None below: the read path holds the engine immutably, so
5904            // RAISE messages have nowhere session-bound to go — B3 residual.)
5905            None,
5906            None,
5907        )
5908        .map_err(|e| EvalError::TypeMismatch {
5909            detail: alloc::format!("{e}"),
5910        })?;
5911        let declared = def.returns.trim();
5912        let Some(v) = out else {
5913            if declared.eq_ignore_ascii_case("VOID") {
5914                return Ok(Value::Null);
5915            }
5916            // PG: a non-void function that falls out of the bottom.
5917            return Err(EvalError::TypeMismatch {
5918                detail: alloc::format!(
5919                    "control reached end of function {:?} without RETURN",
5920                    def.name
5921                ),
5922            });
5923        };
5924        if declared.eq_ignore_ascii_case("VOID") {
5925            return Ok(Value::Null);
5926        }
5927        crate::eval::cast::cast_value(
5928            v.into_owned(),
5929            spg_sql::ast::CastTarget::Named(alloc::string::String::from(declared)),
5930        )
5931        .or_else(|_| Ok(Value::Null))
5932    }
5933}
5934
5935impl crate::Engine {
5936    /// v7.39 (read01 round 66) — run a `RETURNS SETOF` plpgsql function and
5937    /// collect the rows `RETURN NEXT` / `RETURN QUERY` appended. Same
5938    /// interpreter, same read-path resolvers as the scalar call — the only
5939    /// difference is that a SINK is provided, which is what makes those two
5940    /// statements legal.
5941    pub(crate) fn call_plpgsql_setof_fn(
5942        &self,
5943        def: &spg_storage::FunctionDef,
5944        arg_names: &[alloc::string::String],
5945        args: &[Value<'static>],
5946    ) -> Result<alloc::vec::Vec<alloc::vec::Vec<Value<'static>>>, EvalError> {
5947        let block =
5948            spg_sql::parse_function_body(def.body.trim()).map_err(|e| EvalError::TypeMismatch {
5949                detail: alloc::format!("function {:?} body does not parse: {e}", def.name),
5950            })?;
5951        let mut locals: alloc::collections::BTreeMap<alloc::string::String, Value<'static>> =
5952            alloc::collections::BTreeMap::new();
5953        for (i, n) in arg_names.iter().enumerate() {
5954            if n.is_empty() {
5955                continue;
5956            }
5957            locals.insert(
5958                n.to_ascii_lowercase(),
5959                args.get(i).cloned().unwrap_or(Value::Null),
5960            );
5961        }
5962        let dts = self
5963            .session_param("default_text_search_config")
5964            .map(alloc::string::String::from);
5965        let run_select = |stmt: &spg_sql::ast::Statement,
5966                          what: &str|
5967         -> Result<crate::QueryResult, crate::triggers::TriggerError> {
5968            let spg_sql::ast::Statement::Select(s) = stmt else {
5969                return Err(crate::triggers::TriggerError::EvalFailed {
5970                    function: def.name.clone(),
5971                    cause: EvalError::TypeMismatch {
5972                        detail: alloc::format!("{what} body must be a SELECT"),
5973                    },
5974                });
5975            };
5976            self.exec_select_cancel(s, crate::CancelToken::none())
5977                .map_err(|e| crate::triggers::TriggerError::EvalFailed {
5978                    function: def.name.clone(),
5979                    cause: EvalError::TypeMismatch {
5980                        detail: alloc::format!("{what} failed: {e}"),
5981                    },
5982                })
5983        };
5984        let select_into = |stmt: &spg_sql::ast::Statement| -> Result<
5985            Value<'static>,
5986            crate::triggers::TriggerError,
5987        > {
5988            match run_select(stmt, "SELECT … INTO")? {
5989                crate::QueryResult::Rows { rows, .. } => Ok(rows
5990                    .into_iter()
5991                    .next()
5992                    .and_then(|r| r.values.into_iter().next())
5993                    .unwrap_or(Value::Null)),
5994                _ => Ok(Value::Null),
5995            }
5996        };
5997        let for_query = |stmt: &spg_sql::ast::Statement| -> Result<
5998            (
5999                alloc::vec::Vec<alloc::string::String>,
6000                alloc::vec::Vec<alloc::vec::Vec<Value<'static>>>,
6001            ),
6002            crate::triggers::TriggerError,
6003        > {
6004            match run_select(stmt, "FOR … IN / RETURN QUERY")? {
6005                crate::QueryResult::Rows { columns, rows } => Ok((
6006                    columns.iter().map(|c| c.name.clone()).collect(),
6007                    rows.into_iter().map(|r| r.values).collect(),
6008                )),
6009                _ => Ok((alloc::vec::Vec::new(), alloc::vec::Vec::new())),
6010            }
6011        };
6012        let sink: core::cell::RefCell<alloc::vec::Vec<alloc::vec::Vec<Value<'static>>>> =
6013            core::cell::RefCell::new(alloc::vec::Vec::new());
6014        crate::triggers::call_plpgsql_scalar(
6015            &def.name,
6016            &block,
6017            locals,
6018            dts.as_deref(),
6019            Some(&select_into),
6020            Some(&for_query),
6021            Some(&sink),
6022            // Read path — immutable engine borrow; B3 residual.
6023            None,
6024        )
6025        .map_err(|e| EvalError::TypeMismatch {
6026            detail: alloc::format!("{e}"),
6027        })?;
6028        Ok(sink.into_inner())
6029    }
6030}
6031
6032/// v7.39 (round 236) — resolve an ARRAY constructor's element types the way
6033/// PG does. Typed elements must share a type category; a bare string or NULL
6034/// literal is untyped and converts to whatever the typed elements resolved
6035/// to, reporting the value (not a type mismatch) when it will not convert.
6036fn unify_array_elements(
6037    items: &[Expr],
6038    materialised: &mut [Value<'static>],
6039) -> Result<(), EvalError> {
6040    unify_construct_values("ARRAY", items, materialised)
6041}
6042
6043/// v7.39 (round 238) — an `IN (...)` list must be comparable with its
6044/// needle. Reports the operator the way PG does
6045/// ("operator does not exist: integer = text"), and — like round 237 —
6046/// judges only the operands whose type is genuinely known.
6047fn require_in_list_comparable(
6048    needle: &Expr,
6049    list: &[Expr],
6050    ctx: &EvalContext<'_>,
6051) -> Result<(), EvalError> {
6052    let known_ty = |e: &Expr| {
6053        matches!(e, Expr::Cast { .. } | Expr::Literal(_) | Expr::Column(_))
6054            .then(|| crate::describe::describe_expr(e, ctx.columns).map(|s| s.ty))
6055            .flatten()
6056    };
6057    // An untyped literal adopts the needle's type, so it never conflicts.
6058    //
6059    // v7.39 (round 652) — and so does a cast to one of the reg types.
6060    // `'pg_class'::regclass` carries an oid AND a name, which is why
6061    // `compare` has arms for both, but it DESCRIBES as text — SPG has no
6062    // `DataType` for it. This check ran before any comparison and
6063    // refused `WHERE oid IN ('pg_class'::regclass, …)` as `bigint =
6064    // text`, while the identical `oid = 'pg_class'::regclass` worked.
6065    // That shape is how pg_dump, ORMs and monitoring queries name a
6066    // handful of relations at once, so it is not a corner.
6067    let reg_cast = |e: &Expr| {
6068        match e {
6069            Expr::Cast { target, .. } => match target {
6070                spg_sql::ast::CastTarget::RegClass | spg_sql::ast::CastTarget::RegType => true,
6071                // `regproc` and `regnamespace` have no variant of their
6072                // own; they arrive through the generic named path.
6073                spg_sql::ast::CastTarget::Named(n) => {
6074                    n.eq_ignore_ascii_case("regproc")
6075                        || n.eq_ignore_ascii_case("regnamespace")
6076                        || n.eq_ignore_ascii_case("regtype")
6077                        || n.eq_ignore_ascii_case("regclass")
6078                }
6079                _ => false,
6080            },
6081            _ => false,
6082        }
6083    };
6084    let untyped = |e: &Expr| {
6085        reg_cast(e)
6086            || matches!(
6087                e,
6088                Expr::Literal(spg_sql::ast::Literal::String(_))
6089                    | Expr::Literal(spg_sql::ast::Literal::Null)
6090            )
6091    };
6092    // The needle can be untyped too (`NULL IN (1,2)`): SPG has no `Unknown`
6093    // DataType, so a bare NULL describes as TEXT and would look like a text
6094    // needle conflicting with integer list items.
6095    if untyped(needle) {
6096        return Ok(());
6097    }
6098    let Some(nt) = known_ty(needle) else {
6099        return Ok(());
6100    };
6101    for item in list {
6102        if untyped(item) {
6103            continue;
6104        }
6105        let Some(it) = known_ty(item) else { continue };
6106        if !crate::conversions::types_unify(nt, it) {
6107            return Err(EvalError::TypeMismatch {
6108                detail: alloc::format!(
6109                    "operator does not exist: {} = {}",
6110                    crate::conversions::pg_type_name_for_error(nt),
6111                    crate::conversions::pg_type_name_for_error(it),
6112                ),
6113            });
6114        }
6115    }
6116    Ok(())
6117}
6118
6119/// v7.39 (round 237) — STATIC branch-type resolution for the constructs
6120/// whose branches must not all be evaluated: CASE runs only the branch it
6121/// takes, and a COALESCE / GREATEST argument may have side effects
6122/// (`COALESCE(nextval('s'), 1)`), so the check reads each branch's declared
6123/// type instead of its value. Same rule and wording as the value-driven
6124/// ARRAY path below; an untyped literal is converted here (a literal has no
6125/// side effects) so a value that will not convert is reported as PG does.
6126/// v7.39 (round 609) — takes anything that yields the branches, so the
6127/// COALESCE caller no longer builds a `Vec<&Expr>` of them for every row.
6128pub(crate) fn unify_branch_types_static<'e>(
6129    construct: &str,
6130    branches: impl IntoIterator<Item = &'e Expr> + Clone,
6131    ctx: &EvalContext<'_>,
6132) -> Result<(), EvalError> {
6133    use spg_storage::DataType;
6134    let untyped = |e: &Expr| {
6135        matches!(
6136            e,
6137            Expr::Literal(spg_sql::ast::Literal::String(_))
6138                | Expr::Literal(spg_sql::ast::Literal::Null)
6139        )
6140    };
6141    let mut resolved: Option<DataType> = None;
6142    for e in branches.clone() {
6143        if untyped(e) {
6144            continue;
6145        }
6146        // Only branches whose type is GENUINELY known take part. A general
6147        // `describe_expr` is a best-effort hint for wire type tags, not a
6148        // type checker: it reports a binary operator as its left operand's
6149        // type, so `payload->'a'` (jsonb in PG) came back as text and this
6150        // check refused a working `COALESCE(payload->'a', '{}'::jsonb)`.
6151        // Refusing a valid query is worse than missing an invalid one, so
6152        // the check confines itself to an explicit cast, a typed literal and
6153        // a plain column reference.
6154        let known = matches!(e, Expr::Cast { .. } | Expr::Literal(_) | Expr::Column(_));
6155        if !known {
6156            continue;
6157        }
6158        let Some(ty) = crate::describe::describe_expr_type(e, ctx.columns) else {
6159            continue;
6160        };
6161        match resolved {
6162            None => resolved = Some(ty),
6163            Some(prev) if crate::conversions::types_unify(prev, ty) => {
6164                if matches!(prev, DataType::Int | DataType::SmallInt) {
6165                    resolved = Some(ty);
6166                }
6167            }
6168            Some(prev) => {
6169                return Err(EvalError::TypeMismatch {
6170                    detail: alloc::format!(
6171                        "{construct} types {} and {} cannot be matched",
6172                        crate::conversions::pg_type_name_for_error(prev),
6173                        crate::conversions::pg_type_name_for_error(ty),
6174                    ),
6175                });
6176            }
6177        }
6178    }
6179    let Some(target) = resolved else {
6180        return Ok(());
6181    };
6182    if matches!(target, DataType::Text) {
6183        return Ok(());
6184    }
6185    // v7.39 (round 398) — MySQL aggregates a mixed int/string CASE /
6186    // COALESCE to a string (`CASE WHEN 1 THEN 1 ELSE 'x' END` is '1', not an
6187    // error); PG requires the untyped string literals to coerce to the
6188    // resolved numeric type, so it refuses. Under the dialect, skip that
6189    // coercion check — the value is returned as-is / widened by the caller.
6190    if ctx.mysql_dialect {
6191        return Ok(());
6192    }
6193    for e in branches {
6194        if !untyped(e) {
6195            continue;
6196        }
6197        if let Expr::Literal(spg_sql::ast::Literal::String(lit)) = e {
6198            crate::conversions::coerce_value(Value::text(lit.clone()), target, "", 0).map_err(
6199                |err| match err {
6200                    crate::EngineError::Eval(ev) => ev,
6201                    other => EvalError::TypeMismatch {
6202                        detail: alloc::format!("{other}"),
6203                    },
6204                },
6205            )?;
6206        }
6207    }
6208    Ok(())
6209}
6210
6211/// v7.39 (round 237) — the same resolution for every construct that builds
6212/// one value out of several branches: ARRAY, CASE, COALESCE, GREATEST and
6213/// LEAST. PG names the construct in the message ("CASE types text and
6214/// integer cannot be matched"), which is why the caller passes it in.
6215pub(crate) fn unify_construct_values(
6216    construct: &str,
6217    items: &[Expr],
6218    materialised: &mut [Value<'static>],
6219) -> Result<(), EvalError> {
6220    use spg_storage::DataType;
6221    let untyped = |e: &Expr| {
6222        matches!(
6223            e,
6224            Expr::Literal(spg_sql::ast::Literal::String(_))
6225                | Expr::Literal(spg_sql::ast::Literal::Null)
6226        )
6227    };
6228    // The type the typed elements agree on, if any.
6229    let mut resolved: Option<DataType> = None;
6230    for (i, v) in materialised.iter().enumerate() {
6231        if items.get(i).is_some_and(untyped) {
6232            continue;
6233        }
6234        let Some(ty) = v.data_type() else { continue };
6235        match resolved {
6236            None => resolved = Some(ty),
6237            Some(prev) if crate::conversions::types_unify(prev, ty) => {
6238                // Keep the wider of the two so the coercion below targets it.
6239                if matches!(prev, DataType::Int | DataType::SmallInt) {
6240                    resolved = Some(ty);
6241                }
6242            }
6243            Some(prev) => {
6244                return Err(EvalError::TypeMismatch {
6245                    detail: alloc::format!(
6246                        "{construct} types {} and {} cannot be matched",
6247                        crate::conversions::pg_type_name_for_error(prev),
6248                        crate::conversions::pg_type_name_for_error(ty),
6249                    ),
6250                });
6251            }
6252        }
6253    }
6254    // Untyped literals adopt that type; a failure names the value, as PG does.
6255    let Some(target) = resolved else {
6256        return Ok(());
6257    };
6258    if matches!(target, DataType::Text) {
6259        return Ok(());
6260    }
6261    for (i, v) in materialised.iter_mut().enumerate() {
6262        if !items.get(i).is_some_and(untyped) || matches!(v, Value::Null) {
6263            continue;
6264        }
6265        *v = crate::conversions::coerce_value(v.clone(), target, "", i).map_err(|e| match e {
6266            crate::EngineError::Eval(ev) => ev,
6267            other => EvalError::TypeMismatch {
6268                detail: alloc::format!("{other}"),
6269            },
6270        })?;
6271    }
6272    Ok(())
6273}
6274
6275/// v7.39 (round 309, V30) — split `'<timestamp> <zone name>'` into the
6276/// wall-clock reading and the zone token, for the zone-less target types.
6277///
6278/// Returns `None` when the literal parses on its own (nothing to strip)
6279/// or when the trailing token is not zone-SHAPED — those keep the
6280/// ordinary "invalid input syntax" path, which is what PG answers for
6281/// `'2020-01-01 10:00:00 xyz'`. Whether a zone-shaped token is a REAL
6282/// zone is the caller's question; getting that wrong is a different
6283/// error in PG, and conflating the two would report a malformed literal
6284/// for a merely-misspelled zone.
6285///
6286/// Deliberately does not accept a bare time (`'10:00:00 America/New_York'`):
6287/// PG refuses a named zone there, and only reaches this spelling through
6288/// a full timestamp literal.
6289fn split_trailing_zone_name(txt: &str, order: format::DateOrder) -> Option<(i64, &str)> {
6290    // Already valid without help — leave it alone.
6291    if format::parse_timestamp_literal_wall_ordered(txt, order).is_some() {
6292        return None;
6293    }
6294    let trimmed = txt.trim_end();
6295    let idx = trimmed.rfind(' ')?;
6296    let (head, tail) = (trimmed[..idx].trim(), trimmed[idx + 1..].trim());
6297    // An era marker is part of the timestamp, not a zone.
6298    let zone_shaped = tail.len() > 1
6299        && tail.bytes().any(|b| b.is_ascii_alphabetic())
6300        && !tail.eq_ignore_ascii_case("bc")
6301        && !tail.eq_ignore_ascii_case("ad");
6302    if !zone_shaped {
6303        return None;
6304    }
6305    let wall = format::parse_timestamp_literal_wall_ordered(head, order)?;
6306    Some((wall, tail))
6307}
6308
6309#[cfg(test)]
6310mod tests {
6311    use super::*;
6312    use alloc::vec;
6313    use spg_sql::ast::UnOp;
6314    use spg_storage::{ColumnSchema, DataType, Row};
6315
6316    fn col(name: &str, ty: DataType) -> ColumnSchema {
6317        ColumnSchema::new(name, ty, true)
6318    }
6319
6320    fn ctx<'a>(cols: &'a [ColumnSchema], alias: Option<&'a str>) -> EvalContext<'a> {
6321        EvalContext::new(cols, alias)
6322    }
6323
6324    /// v7.32 (P4 borrow channel) differential: the borrowed comparison
6325    /// fast path in `eval_expr`'s Binary arm must be byte-for-byte the
6326    /// pre-P4 owned path (`apply_binary` on cloned operands) across a
6327    /// cross-type value matrix and every comparison operator — covering
6328    /// the fast-path types (Text/Int/Float/Date/Timestamp/Bool/Null) and
6329    /// the owned-fallback types (Numeric/Interval).
6330    #[test]
6331    fn borrowed_compare_equals_owned_apply_binary() {
6332        let vals = vec![
6333            Value::Null,
6334            Value::Bool(true),
6335            Value::Bool(false),
6336            Value::SmallInt(3),
6337            Value::Int(3),
6338            Value::Int(-1),
6339            Value::BigInt(3),
6340            Value::BigInt(100),
6341            Value::Float(3.0),
6342            Value::Float(2.5),
6343            Value::text(String::new()),
6344            Value::text("a"),
6345            Value::text("b"),
6346            Value::Date(10),
6347            Value::Timestamp(1000),
6348            Value::Numeric {
6349                scaled: 30,
6350                scale: 1,
6351                kind: spg_storage::NumericKind::Finite,
6352            },
6353            Value::Interval {
6354                months: 0,
6355                days: 0,
6356                micros: 5,
6357            },
6358        ];
6359        let ops = [
6360            BinOp::Eq,
6361            BinOp::NotEq,
6362            BinOp::Lt,
6363            BinOp::LtEq,
6364            BinOp::Gt,
6365            BinOp::GtEq,
6366        ];
6367        let cs = vec![col("x", DataType::Int), col("y", DataType::Int)];
6368        let c = ctx(&cs, None);
6369        let lhs = Expr::Column(ColumnName {
6370            qualifier: None,
6371            name: "x".into(),
6372        });
6373        let rhs = Expr::Column(ColumnName {
6374            qualifier: None,
6375            name: "y".into(),
6376        });
6377        for l in &vals {
6378            for r in &vals {
6379                let row = Row::new(vec![l.clone(), r.clone()]);
6380                for op in ops {
6381                    let got = eval_expr(
6382                        &Expr::Binary {
6383                            lhs: alloc::boxed::Box::new(lhs.clone()),
6384                            op,
6385                            rhs: alloc::boxed::Box::new(rhs.clone()),
6386                        },
6387                        &row,
6388                        &c,
6389                    );
6390                    // Pre-P4 reference: owned operands through apply_binary
6391                    // (collation fold is a no-op for non-CI columns).
6392                    let want = apply_binary(op, l.clone(), r.clone());
6393                    assert_eq!(
6394                        format!("{got:?}"),
6395                        format!("{want:?}"),
6396                        "op={op:?} l={l:?} r={r:?}"
6397                    );
6398                }
6399            }
6400        }
6401    }
6402
6403    fn lit(n: i64) -> Expr {
6404        Expr::Literal(Literal::Integer(n))
6405    }
6406
6407    fn null() -> Expr {
6408        Expr::Literal(Literal::Null)
6409    }
6410
6411    fn col_ref(name: &str) -> Expr {
6412        Expr::Column(ColumnName {
6413            qualifier: None,
6414            name: name.into(),
6415        })
6416    }
6417
6418    #[test]
6419    fn literal_evaluates_to_value() {
6420        let r = Row::new(vec![]);
6421        let cs: [ColumnSchema; 0] = [];
6422        let c = ctx(&cs, None);
6423        assert_eq!(eval_expr(&lit(42), &r, &c).unwrap(), Value::Int(42));
6424        assert_eq!(
6425            eval_expr(&Expr::Literal(Literal::Float(1.5)), &r, &c).unwrap(),
6426            Value::Float(1.5)
6427        );
6428        assert_eq!(eval_expr(&null(), &r, &c).unwrap(), Value::Null);
6429    }
6430
6431    #[test]
6432    fn column_lookup_unqualified() {
6433        let cs = vec![col("a", DataType::Int), col("b", DataType::Text)];
6434        let r = Row::new(vec![Value::Int(7), Value::text("hi")]);
6435        let c = ctx(&cs, None);
6436        assert_eq!(eval_expr(&col_ref("a"), &r, &c).unwrap(), Value::Int(7));
6437        assert_eq!(eval_expr(&col_ref("b"), &r, &c).unwrap(), Value::text("hi"));
6438    }
6439
6440    #[test]
6441    fn column_not_found_errors() {
6442        let cs = vec![col("a", DataType::Int)];
6443        let r = Row::new(vec![Value::Int(0)]);
6444        let c = ctx(&cs, None);
6445        let err = eval_expr(&col_ref("ghost"), &r, &c).unwrap_err();
6446        assert!(matches!(err, EvalError::ColumnNotFound { ref name } if name == "ghost"));
6447    }
6448
6449    #[test]
6450    fn qualified_column_matches_alias() {
6451        let cs = vec![col("a", DataType::Int)];
6452        let r = Row::new(vec![Value::Int(5)]);
6453        let c = ctx(&cs, Some("u"));
6454        let qualified = Expr::Column(ColumnName {
6455            qualifier: Some("u".into()),
6456            name: "a".into(),
6457        });
6458        assert_eq!(eval_expr(&qualified, &r, &c).unwrap(), Value::Int(5));
6459    }
6460
6461    #[test]
6462    fn qualified_column_unknown_alias_errors() {
6463        let cs = vec![col("a", DataType::Int)];
6464        let r = Row::new(vec![Value::Int(5)]);
6465        let c = ctx(&cs, Some("u"));
6466        let wrong = Expr::Column(ColumnName {
6467            qualifier: Some("x".into()),
6468            name: "a".into(),
6469        });
6470        assert!(matches!(
6471            eval_expr(&wrong, &r, &c).unwrap_err(),
6472            EvalError::UnknownQualifier { .. }
6473        ));
6474    }
6475
6476    #[test]
6477    fn arithmetic_with_widening() {
6478        let r = Row::new(vec![]);
6479        let cs: [ColumnSchema; 0] = [];
6480        let c = ctx(&cs, None);
6481        let e = Expr::Binary {
6482            lhs: alloc::boxed::Box::new(lit(2)),
6483            op: BinOp::Add,
6484            rhs: alloc::boxed::Box::new(Expr::Literal(Literal::Float(0.5))),
6485        };
6486        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Float(2.5));
6487    }
6488
6489    #[test]
6490    fn division_by_zero_errors() {
6491        let r = Row::new(vec![]);
6492        let cs: [ColumnSchema; 0] = [];
6493        let c = ctx(&cs, None);
6494        let e = Expr::Binary {
6495            lhs: alloc::boxed::Box::new(lit(1)),
6496            op: BinOp::Div,
6497            rhs: alloc::boxed::Box::new(lit(0)),
6498        };
6499        assert_eq!(
6500            eval_expr(&e, &r, &c).unwrap_err(),
6501            EvalError::DivisionByZero
6502        );
6503    }
6504
6505    #[test]
6506    fn comparison_returns_bool() {
6507        let r = Row::new(vec![]);
6508        let cs: [ColumnSchema; 0] = [];
6509        let c = ctx(&cs, None);
6510        let e = Expr::Binary {
6511            lhs: alloc::boxed::Box::new(lit(1)),
6512            op: BinOp::Lt,
6513            rhs: alloc::boxed::Box::new(lit(2)),
6514        };
6515        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Bool(true));
6516    }
6517
6518    #[test]
6519    fn null_propagates_through_arithmetic() {
6520        let r = Row::new(vec![]);
6521        let cs: [ColumnSchema; 0] = [];
6522        let c = ctx(&cs, None);
6523        let e = Expr::Binary {
6524            lhs: alloc::boxed::Box::new(lit(1)),
6525            op: BinOp::Add,
6526            rhs: alloc::boxed::Box::new(null()),
6527        };
6528        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Null);
6529    }
6530
6531    #[test]
6532    fn stack_depth_guard_trips_on_pathological_nesting() {
6533        // Built directly as an AST — the parser's own budgets (256
6534        // chained binary operators, 64 nesting levels) reject such SQL
6535        // long before eval sees it, so this exercises the eval-side
6536        // guard on its own. 30 000 frames overshoot the 768 KiB budget
6537        // at any conceivable frame size; the guard errors at the byte
6538        // budget, far below the worker stack, so deeper is safer.
6539        let mut e = Expr::Literal(Literal::Bool(true));
6540        for _ in 0..30_000 {
6541            e = Expr::Binary {
6542                lhs: alloc::boxed::Box::new(e),
6543                op: BinOp::And,
6544                rhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(true))),
6545            };
6546        }
6547        let r = Row::new(vec![]);
6548        let cs: [ColumnSchema; 0] = [];
6549        let c = ctx(&cs, None);
6550        let err = eval_expr(&e, &r, &c).unwrap_err();
6551        assert!(matches!(err, EvalError::StackDepthExceeded), "{err:?}");
6552        // Dropping a 30 000-deep Box chain recurses in the drop glue —
6553        // deeper than the eval guard allows the EVAL side to go — so
6554        // leak it rather than gamble on the test thread's stack.
6555        core::mem::forget(e);
6556    }
6557
6558    #[test]
6559    fn and_three_valued_logic() {
6560        let r = Row::new(vec![]);
6561        let cs: [ColumnSchema; 0] = [];
6562        let c = ctx(&cs, None);
6563        let tt = |a: bool, b_null: bool| Expr::Binary {
6564            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(a))),
6565            op: BinOp::And,
6566            rhs: alloc::boxed::Box::new(if b_null {
6567                null()
6568            } else {
6569                Expr::Literal(Literal::Bool(true))
6570            }),
6571        };
6572        // FALSE AND NULL → FALSE
6573        assert_eq!(
6574            eval_expr(&tt(false, true), &r, &c).unwrap(),
6575            Value::Bool(false)
6576        );
6577        // TRUE AND NULL → NULL
6578        assert_eq!(eval_expr(&tt(true, true), &r, &c).unwrap(), Value::Null);
6579        // TRUE AND TRUE → TRUE
6580        assert_eq!(
6581            eval_expr(&tt(true, false), &r, &c).unwrap(),
6582            Value::Bool(true)
6583        );
6584    }
6585
6586    #[test]
6587    fn or_three_valued_logic() {
6588        let r = Row::new(vec![]);
6589        let cs: [ColumnSchema; 0] = [];
6590        let c = ctx(&cs, None);
6591        let or_with_null = |a: bool| Expr::Binary {
6592            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::Bool(a))),
6593            op: BinOp::Or,
6594            rhs: alloc::boxed::Box::new(null()),
6595        };
6596        // TRUE OR NULL → TRUE
6597        assert_eq!(
6598            eval_expr(&or_with_null(true), &r, &c).unwrap(),
6599            Value::Bool(true)
6600        );
6601        // FALSE OR NULL → NULL
6602        assert_eq!(
6603            eval_expr(&or_with_null(false), &r, &c).unwrap(),
6604            Value::Null
6605        );
6606    }
6607
6608    #[test]
6609    fn not_on_null_is_null() {
6610        let r = Row::new(vec![]);
6611        let cs: [ColumnSchema; 0] = [];
6612        let c = ctx(&cs, None);
6613        let e = Expr::Unary {
6614            op: UnOp::Not,
6615            expr: alloc::boxed::Box::new(null()),
6616        };
6617        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Null);
6618    }
6619
6620    #[test]
6621    fn text_comparison_lexicographic() {
6622        let r = Row::new(vec![]);
6623        let cs: [ColumnSchema; 0] = [];
6624        let c = ctx(&cs, None);
6625        let e = Expr::Binary {
6626            lhs: alloc::boxed::Box::new(Expr::Literal(Literal::String("apple".into()))),
6627            op: BinOp::Lt,
6628            rhs: alloc::boxed::Box::new(Expr::Literal(Literal::String("banana".into()))),
6629        };
6630        assert_eq!(eval_expr(&e, &r, &c).unwrap(), Value::Bool(true));
6631    }
6632
6633    #[test]
6634    fn interval_format_basics() {
6635        // v7.37.5 β — three-arg signature. PG byte-equal:
6636        // `'1 day'` ≠ `'24 hours'` now, the format reflects it.
6637        assert_eq!(format_interval(0, 0, 0), "00:00:00");
6638        assert_eq!(format_interval(0, 1, 0), "1 day");
6639        assert_eq!(format_interval(0, -1, 0), "-1 days");
6640        assert_eq!(format_interval(0, 0, 86_400_000_000), "24:00:00");
6641        assert_eq!(format_interval(0, 0, 3_600_000_000), "01:00:00");
6642        assert_eq!(format_interval(0, 1, 9_000_000), "1 day 00:00:09");
6643        assert_eq!(format_interval(14, 0, 0), "1 year 2 mons");
6644        assert_eq!(format_interval(-1, 0, 0), "-1 mons");
6645    }
6646
6647    #[test]
6648    fn interval_format_pg_byte_equal_day_vs_24h() {
6649        // v7.37.5 β — the PG-canonical distinction `'1 day'` ≠ `'24 hours'`
6650        // is preserved in the formatter, not just the parser.
6651        assert_eq!(format_interval(0, 1, 0), "1 day");
6652        assert_eq!(format_interval(0, 0, 86_400_000_000), "24:00:00");
6653        assert_ne!(
6654            format_interval(0, 1, 0),
6655            format_interval(0, 0, 86_400_000_000),
6656        );
6657    }
6658}