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