Skip to main content

spg_engine/
eval.rs

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