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