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