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