Skip to main content

spg_engine/
eval.rs

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