Skip to main content

spg_engine/eval/
cast.rs

1//! `expr::TYPE` CAST evaluation (cut 29 — extracted from `eval.rs`).
2//!
3//! Implements PG-style runtime coercion: the giant `cast_value`
4//! dispatcher plus its per-target helpers (numeric / bool / array /
5//! date / timestamp / interval / vector). Date and timestamp casts
6//! defer to the calendar parsers (`parse_date_literal` /
7//! `parse_timestamp_literal`) that stay in `eval.rs`; tsvector /
8//! tsquery casts defer to the FTS codecs re-exported from
9//! `eval::textsearch`.
10
11use alloc::format;
12use alloc::string::{String, ToString};
13use alloc::vec::Vec;
14
15use spg_sql::ast::CastTarget;
16use spg_storage::Value;
17
18use super::math::{f64_powi, f64_round_half_even};
19use super::{
20    EvalError, decode_tsquery_external, decode_tsvector_external, parse_date_literal,
21    parse_timestamp_literal, value_to_text,
22};
23
24/// v7.39 (read01 regproc.c) — the reg* input types SPG carries as text:
25/// resolve the name and return its canonical rendering, with PG's
26/// distinct not-found / ambiguous errors.
27/// v7.39 (round 515) — the type names each family below accepts, declared
28/// ONCE so the value path and the NULL-target check cannot drift apart.
29///
30/// They already had, three times. Round 509 added a check that a cast target
31/// names something, with its own hand-kept list; round 512 then added
32/// `Value::Cid` without registering `cid` there, round 514 found that and
33/// `xid`, and round 515 found six more — `'english'::regconfig` resolved
34/// while `NULL::regconfig` did not. The duplication was the defect, so it
35/// is gone rather than patched a third time.
36pub(crate) const REG_MISC_TYPES: &[&str] = &[
37    "regproc",
38    "regprocedure",
39    "regoper",
40    "regoperator",
41    "regconfig",
42    "regdictionary",
43    "regcollation",
44];
45
46/// The catalog-shaped scalars — see `cast_catalog_scalar`.
47pub(crate) const CATALOG_SCALAR_TYPES: &[&str] = &[
48    "cid",
49    "xid",
50    "oidvector",
51    "int2vector",
52    "aclitem",
53    "refcursor",
54    "pg_snapshot",
55    "txid_snapshot",
56    "jsonpath",
57];
58
59/// The pseudotypes and the statistics / GiST internals: a NULL is NULL, a
60/// value is "cannot accept a value of type X".
61pub(crate) const OPAQUE_TYPES: &[&str] = &[
62    "anyarray",
63    "anyelement",
64    "anyenum",
65    "anyrange",
66    "anymultirange",
67    "anynonarray",
68    "anycompatible",
69    "anycompatiblearray",
70    "anycompatiblenonarray",
71    "anycompatiblerange",
72    "anycompatiblemultirange",
73    "any",
74    "trigger",
75    "event_trigger",
76    "internal",
77    "language_handler",
78    "fdw_handler",
79    "pg_ddl_command",
80    "pg_node_tree",
81    "pg_ndistinct",
82    "pg_mcv_list",
83    "pg_dependencies",
84    "pg_brin_minmax_multi_summary",
85    "pg_brin_bloom_summary",
86    "gtsvector",
87];
88
89fn cast_reg_misc(kind: &str, s: &str) -> Result<Value<'static>, EvalError> {
90    let bare = s
91        .strip_prefix("pg_catalog.")
92        .unwrap_or(s)
93        .trim()
94        .to_string();
95    match kind {
96        "regconfig" => {
97            const CONFIGS: &[&str] = &[
98                "simple",
99                "arabic",
100                "armenian",
101                "basque",
102                "catalan",
103                "danish",
104                "dutch",
105                "english",
106                "finnish",
107                "french",
108                "german",
109                "greek",
110                "hindi",
111                "hungarian",
112                "indonesian",
113                "irish",
114                "italian",
115                "lithuanian",
116                "nepali",
117                "norwegian",
118                "portuguese",
119                "romanian",
120                "russian",
121                "serbian",
122                "spanish",
123                "swedish",
124                "tamil",
125                "turkish",
126                "yiddish",
127            ];
128            if CONFIGS.contains(&bare.as_str()) {
129                Ok(Value::text(bare))
130            } else {
131                Err(EvalError::TypeMismatch {
132                    detail: alloc::format!("text search configuration \"{s}\" does not exist"),
133                })
134            }
135        }
136        // v7.39 (round 513) — `regcollation`. PG lowercases an UNQUOTED
137        // identifier before it looks, which is why `'C'::regcollation` is
138        // "collation \"c\" for encoding \"UTF8\" does not exist" there while
139        // `'\"C\"'::regcollation` resolves — measured, and the reason the
140        // quoted form is the one anybody writes. The rendering keeps the
141        // quotes PG puts back on a name that needs them.
142        "regcollation" => {
143            let quoted = bare.starts_with('"') && bare.ends_with('"') && bare.len() >= 2;
144            let name = if quoted {
145                bare[1..bare.len() - 1].to_string()
146            } else {
147                bare.to_ascii_lowercase()
148            };
149            const COLLATIONS: &[&str] = &["C", "POSIX", "default", "ucs_basic"];
150            match COLLATIONS.iter().find(|c| **c == name) {
151                // PG re-quotes anything that is not a plain lowercase word.
152                Some(c) => Ok(Value::text(
153                    if c.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_') && *c != "default" {
154                        (*c).to_string()
155                    } else {
156                        alloc::format!("\"{c}\"")
157                    },
158                )),
159                None => Err(EvalError::TypeMismatch {
160                    detail: alloc::format!(
161                        "collation \"{name}\" for encoding \"UTF8\" does not exist"
162                    ),
163                }),
164            }
165        }
166        "regdictionary" => {
167            if bare == "simple" || bare.ends_with("_stem") {
168                Ok(Value::text(bare))
169            } else {
170                Err(EvalError::TypeMismatch {
171                    detail: alloc::format!("text search dictionary \"{s}\" does not exist"),
172                })
173            }
174        }
175        "regproc" => {
176            let hits = crate::system_catalog::PG_PROC_FUNCS
177                .iter()
178                .filter(|(_, n, ..)| *n == bare)
179                .count();
180            match hits {
181                0 => Err(EvalError::TypeMismatch {
182                    detail: alloc::format!("function \"{s}\" does not exist"),
183                }),
184                1 => Ok(Value::text(bare)),
185                _ => Err(EvalError::TypeMismatch {
186                    detail: alloc::format!("more than one function named \"{bare}\""),
187                }),
188            }
189        }
190        "regprocedure" => {
191            // `name(argtype, ...)` — resolve the name, canonicalize each
192            // argument type, and re-render.
193            let Some((fname, rest)) = bare.split_once('(') else {
194                return Err(EvalError::TypeMismatch {
195                    detail: alloc::format!("expected a left parenthesis in \"{s}\""),
196                });
197            };
198            let Some(args_txt) = rest.strip_suffix(')') else {
199                return Err(EvalError::TypeMismatch {
200                    detail: alloc::format!("expected a right parenthesis in \"{s}\""),
201                });
202            };
203            let fname = fname.trim().to_ascii_lowercase();
204            let args: Vec<String> = if args_txt.trim().is_empty() {
205                Vec::new()
206            } else {
207                args_txt
208                    .split(',')
209                    .map(|a| {
210                        crate::conversions::regtype_canonical_name(a.trim()).ok_or_else(|| {
211                            EvalError::TypeMismatch {
212                                detail: alloc::format!("type \"{}\" does not exist", a.trim()),
213                            }
214                        })
215                    })
216                    .collect::<Result<_, _>>()?
217            };
218            let nargs = args.len() as i32;
219            let known = crate::system_catalog::PG_PROC_FUNCS
220                .iter()
221                .any(|(_, n, _, na, _)| *n == fname && *na == nargs);
222            if !known {
223                return Err(EvalError::TypeMismatch {
224                    detail: alloc::format!("function \"{s}\" does not exist"),
225                });
226            }
227            Ok(Value::text(alloc::format!("{fname}({})", args.join(","))))
228        }
229        // regoper / regoperator: SPG has no operator catalog; every core
230        // operator symbol is multiply overloaded in PG, so a known symbol
231        // reports PG's ambiguity and anything else does not exist.
232        _ => {
233            let sym: String = bare.chars().filter(|c| !c.is_whitespace()).collect();
234            let core_op = !sym.is_empty() && sym.chars().all(|c| "+-*/<>=~!@#%^&|`?".contains(c));
235            if core_op {
236                Err(EvalError::TypeMismatch {
237                    detail: alloc::format!("more than one operator named {sym}"),
238                })
239            } else {
240                Err(EvalError::TypeMismatch {
241                    detail: alloc::format!("operator does not exist: {s}"),
242                })
243            }
244        }
245    }
246}
247
248/// v7.39 (round 355, M13) — `BINARY expr` / `CAST(expr AS BINARY[(n)])`.
249fn cast_mysql_binary(v: Value<'static>, name: &str) -> Result<Value<'static>, EvalError> {
250    let limit: Option<usize> = name
251        .split_once('(')
252        .and_then(|(_, rest)| rest.trim_end_matches(')').trim().parse().ok());
253    // v7.39.2 — a binary source STAYS binary. It used to be rendered
254    // with PG's bytea text, so `CAST(0x41 AS BINARY)` answered the six
255    // characters `\x41` where MySQL 9.7.2 answers the byte 'A', and
256    // `HEX(CAST(0x00FF AS BINARY))` would have described that text
257    // rather than the bytes. `(n)` truncates to n BYTES there —
258    // `HEX(CAST(0x4142 AS BINARY(1)))` is 41, measured.
259    if let Value::Bytes(b) = &v {
260        let bytes = match limit {
261            Some(n) if b.len() > n => b[..n].to_vec(),
262            _ => b.clone().into_owned(),
263        };
264        return Ok(Value::bytes(bytes));
265    }
266    let text = match &v {
267        Value::Null => return Ok(Value::Null),
268        Value::Text(t) => t.to_string(),
269        Value::BpChar(t) => t.to_string(),
270        other => crate::eval::values::value_to_text(other),
271    };
272    Ok(Value::text(match limit {
273        // Byte-wise, which is the point of the type.
274        Some(n) if text.len() > n => {
275            let mut cut = n;
276            while cut > 0 && !text.is_char_boundary(cut) {
277                cut -= 1;
278            }
279            text[..cut].to_string()
280        }
281        _ => text,
282    }))
283}
284
285/// v7.39 (round 352, M8) — `CAST(x AS SIGNED)` / `CAST(x AS UNSIGNED)`.
286fn cast_mysql_integer(v: Value<'static>, unsigned: bool) -> Result<Value<'static>, EvalError> {
287    // v7.39 (round 527) — an EXACT integer source must not round-trip
288    // through f64. It loses precision above 2^53, and the float→int cast
289    // SATURATES, so `CAST(18446744073709551615 AS UNSIGNED)` answered
290    // 9223372036854775807 — a different number, with nothing to say so.
291    // The value stores, compares and sums correctly at full width
292    // (measured against MariaDB 11); only the cast reduced it.
293    let exact: Option<i128> = match &v {
294        Value::Bool(b) => Some(i128::from(u8::from(*b))),
295        Value::SmallInt(x) => Some(i128::from(*x)),
296        Value::Int(x) => Some(i128::from(*x)),
297        Value::BigInt(x) => Some(i128::from(*x)),
298        Value::Numeric {
299            scaled,
300            scale: 0,
301            kind: spg_storage::NumericKind::Finite,
302        } => Some(*scaled),
303        _ => None,
304    };
305    let rounded: i128 = match exact {
306        Some(n) => n,
307        None => {
308            let n: f64 = match &v {
309                Value::Null => return Ok(Value::Null),
310                Value::Float(x) => *x,
311                Value::Real(x) => f64::from(*x),
312                #[allow(clippy::cast_precision_loss)]
313                Value::Numeric { scaled, scale, .. } => {
314                    *scaled as f64 / 10_f64.powi(i32::from(*scale))
315                }
316                Value::Text(t) | Value::BpChar(t) => crate::eval::mysql_leading_number(t),
317                other => {
318                    return Err(EvalError::TypeMismatch {
319                        detail: alloc::format!(
320                            "cannot cast {} to integer",
321                            crate::conversions::pg_type_name_for_error_opt(other.data_type())
322                        ),
323                    });
324                }
325            };
326            // Half away from zero, which is what MariaDB does
327            // (2.5 → 3, -2.5 → -3).
328            let r = if n >= 0.0 {
329                (n + 0.5).floor()
330            } else {
331                (n - 0.5).ceil()
332            };
333            #[allow(clippy::cast_possible_truncation)]
334            let as_i64 = r as i64;
335            i128::from(as_i64)
336        }
337    };
338    if unsigned {
339        // MariaDB wraps a negative through the full u64 range.
340        let wrapped: u64 = if rounded < 0 {
341            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
342            {
343                rounded as i64 as u64
344            }
345        } else {
346            u64::try_from(rounded).unwrap_or(u64::MAX)
347        };
348        // Above i64::MAX the value only fits the numeric carrier, which
349        // is the same one a BIGINT UNSIGNED column already uses.
350        return Ok(if wrapped > i64::MAX as u64 {
351            Value::Numeric {
352                scaled: i128::from(wrapped),
353                scale: 0,
354                kind: spg_storage::NumericKind::Finite,
355            }
356        } else {
357            #[allow(clippy::cast_possible_wrap)]
358            Value::BigInt(wrapped as i64)
359        });
360    }
361    Ok(Value::BigInt(
362        i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }),
363    ))
364}
365
366/// v7.39 (round 544) — the integer a bytea's bytes spell: big-endian,
367/// right-aligned into `width` bytes, sign-extended from the leading
368/// byte when the value already fills the width. Measured on PG18:
369/// `'\x05'::bytea::int8` is 5, `'\x'::bytea::int4` is 0,
370/// `'\xffffffff'::bytea::int4` is -1.
371#[inline(never)]
372fn bytea_to_integer(v: &Value<'static>, width: usize) -> Result<Value<'static>, EvalError> {
373    let Value::Bytes(b) = v else {
374        return Err(EvalError::TypeMismatch {
375            detail: alloc::string::String::from("expected bytea"),
376        });
377    };
378    if b.len() > width {
379        return Err(EvalError::TypeMismatch {
380            detail: alloc::format!("bytea of {} bytes is too wide for the target", b.len()),
381        });
382    }
383    let negative = b.len() == width && b.first().is_some_and(|f| *f & 0x80 != 0);
384    let mut acc: i64 = if negative { -1 } else { 0 };
385    for byte in b.iter() {
386        acc = (acc << 8) | i64::from(*byte);
387    }
388    Ok(if width == 4 {
389        Value::Int(i32::try_from(acc).unwrap_or(0))
390    } else {
391        Value::BigInt(acc)
392    })
393}
394
395/// Round a numeric operand (`scaled` × 10^-`scale`) to the nearest
396/// integer, half-away-from-zero — PG's `numeric → int` coercion rule.
397fn numeric_round_to_i128(scaled: i128, scale: u16) -> i128 {
398    let factor = 10_i128.pow(u32::from(scale));
399    let neg = scaled < 0;
400    let abs = scaled.unsigned_abs() as i128;
401    let q = abs / factor;
402    let r = abs % factor;
403    let mag = if 2 * r >= factor { q + 1 } else { q };
404    if neg { -mag } else { mag }
405}
406
407/// PG-style `expr::TYPE` coercion. NULL always casts as NULL.
408pub fn cast_value(v: Value<'static>, target: CastTarget) -> Result<Value<'static>, EvalError> {
409    cast_value_in(v, target, false)
410}
411
412/// v7.39 (round 352, M8) — `cast_value` with the session dialect, for the
413/// targets the two disagree about (`SIGNED` / `UNSIGNED` exist only in
414/// MySQL: PG says `type "signed" does not exist`, measured).
415pub fn cast_value_in(
416    v: Value<'static>,
417    target: CastTarget,
418    mysql: bool,
419) -> Result<Value<'static>, EvalError> {
420    cast_value_ref_in(v, &target, mysql)
421}
422
423/// v7.39 (round 607) — the same dispatch, taking the target by REFERENCE.
424///
425/// `eval_cast_arm` cloned the target for every row. For the settled variants
426/// that clone is free, which is why `id::FLOAT` allocated nothing a row while
427/// `id::REAL` — the same conversion under a name the parser leaves as
428/// `Named(String)` — allocated one just to hand the name over, and seven more
429/// re-deriving its lowercase form inside.
430/// v7.39.2 — is this cast target a character type, for the purpose of
431/// reading a binary string's bytes as its characters?
432///
433/// `BINARY` and `BINARY(n)` are deliberately absent: MySQL keeps those
434/// binary, and the bytes go out as bytes.
435fn cast_reads_bytes_as_characters(target: &CastTarget) -> bool {
436    match target {
437        CastTarget::Text => true,
438        CastTarget::Named(n) => {
439            let lc = n.to_ascii_lowercase();
440            let base = lc.split('(').next().unwrap_or(&lc);
441            matches!(
442                base,
443                "char" | "varchar" | "character" | "nchar" | "nvarchar" | "text"
444            )
445        }
446        _ => false,
447    }
448}
449
450pub fn cast_value_ref_in(
451    v: Value<'static>,
452    target: &CastTarget,
453    mysql: bool,
454) -> Result<Value<'static>, EvalError> {
455    // v7.37 (round 896) — the quoted spelling of these two arrives as
456    // `Named`, the bare one as its own variant, and only the variant had an
457    // arm. `::regclass` worked and `::"regclass"` answered `type "regclass"
458    // does not exist` — and quoted identifiers are what an ORM or pg_dump
459    // writes. Folding here rather than adding a second arm keeps one
460    // implementation: whatever the variant does, the quoted form now does.
461    // Round 894 fixed `tsvector` / `tsquery` the same way round and left
462    // these open because this path had not been read; it has now.
463    // v7.39.2 — MySQL reads a binary string's BYTES when it casts to a
464    // character type. This path rendered it PG's way instead, so
465    // `CAST(0x41 AS CHAR)` answered the six characters `\x41`, and the
466    // answers built on it were wrong without saying so: measured on MySQL
467    // 9.7.2, `CAST(0x41 AS CHAR)='A'` is 1 and was 0 here, and
468    // `LENGTH(CAST(0x4142 AS CHAR))` is 2 and was 6.
469    //
470    // `CAST(x AS CHAR)` lowers onto `Text` in the MySQL dialect (it is
471    // UNBOUNDED there, round 352); `CHAR(n)` / `VARCHAR(n)` arrive named.
472    let v = match &v {
473        Value::Bytes(b) if mysql && cast_reads_bytes_as_characters(target) => Value::text(
474            b.iter()
475                .map(|&x| x as char)
476                .collect::<alloc::string::String>(),
477        ),
478        _ => v,
479    };
480    if let CastTarget::Named(n) = target {
481        if n.eq_ignore_ascii_case("regclass") {
482            return cast_value_ref_in(v, &CastTarget::RegClass, mysql);
483        }
484        if n.eq_ignore_ascii_case("regtype") {
485            return cast_value_ref_in(v, &CastTarget::RegType, mysql);
486        }
487    }
488    // v7.39 (round 509) — PG validates the cast TARGET whatever the operand
489    // is: `NULL::nosuchtype` is an error there, not NULL. This returned early
490    // before ever looking at the target, so a misspelt type name silently
491    // produced NULL and `pg_typeof(NULL::nosuchtype)` answered `unknown`. A
492    // value operand DID error, so the gap was exactly the NULL case, in both
493    // spellings (`::t` and `CAST(… AS t)`).
494    //
495    // Only `Named` can fail to resolve; every other CastTarget is a variant
496    // the parser already settled. So a NULL keeps its short-circuit
497    // everywhere else and a Named target runs the real path, which is the
498    // only thing that knows every name that resolves. Writing a second
499    // resolver to check the name against looked simpler and was wrong: it
500    // missed `::binary` (the MySQL prefix's desugar), a table's row type,
501    // and the pseudotypes, all of which resolve further down this arm.
502    if matches!(v, Value::Null) {
503        return Ok(Value::Null);
504    }
505    match target {
506        CastTarget::Vector => cast_to_vector(v),
507        // v7.38 (read01) — the inet/cidr ::text cast shows the mask even for
508        // /32 and /128 (PG's cast-path form, unlike the display default).
509        CastTarget::Text => Ok(Value::text(match &v {
510            Value::Inet { family, bits, addr } | Value::Cidr { family, bits, addr } => {
511                crate::conversions::format_inet_full(*family, *bits, addr)
512            }
513            // v7.38 (read01, T11) — bpchar → text strips the trailing blanks
514            // (unlike the padded wire display).
515            Value::BpChar(s) => s.trim_end_matches(' ').to_string(),
516            // v7.39 (read01 ruleutils.c) — regclass::text is the name.
517            Value::RegClass(_, name) | Value::RegProc(_, name) | Value::RegType(_, name) => {
518                name.to_string()
519            }
520            _ => value_to_text(&v),
521        })),
522        // v7.39 (round 254) — the integer targets refuse a NUMERIC special
523        // outright (PG: `cannot convert NaN to integer`); without this the
524        // arms below read the special's canonical mantissa and answered 0.
525        // The float / numeric targets pass it through instead — handled in
526        // their own arms, which now consult `kind`.
527        // v7.39 (round 343) — an OID-typed reference casts to an integer
528        // the way PG's do (`'t'::regclass::bigint` is 27830 there). SPG
529        // reported `cannot cast None to bigint`: the integer path read the
530        // value's storage DataType, which these two deliberately do not
531        // have, and the message leaked that `None` to the client.
532        CastTarget::BigInt | CastTarget::Int
533            if matches!(
534                v,
535                Value::RegClass(..) | Value::RegProc(..) | Value::RegType(..)
536            ) =>
537        {
538            let (Value::RegClass(oid, _) | Value::RegProc(oid, _) | Value::RegType(oid, _)) = v
539            else {
540                unreachable!("guarded above")
541            };
542            Ok(if matches!(target, CastTarget::BigInt) {
543                Value::BigInt(oid)
544            } else {
545                Value::Int(i32::try_from(oid).unwrap_or(i32::MAX))
546            })
547        }
548        // v7.39 (round 544) — bytea reads back as the integer its bytes
549        // spell, big-endian and right-aligned. Measured on PG18:
550        // '\x05'::bytea::int8 is 5, '\x'::bytea::int4 is 0.
551        CastTarget::Int if matches!(v, Value::Bytes(_)) => bytea_to_integer(&v, 4),
552        CastTarget::BigInt if matches!(v, Value::Bytes(_)) => bytea_to_integer(&v, 8),
553        CastTarget::Int => {
554            cast_numeric_special_reject(&v, "integer").unwrap_or_else(|| cast_numeric_to_int(v))
555        }
556        CastTarget::BigInt => {
557            cast_numeric_special_reject(&v, "bigint").unwrap_or_else(|| cast_numeric_to_bigint(v))
558        }
559        CastTarget::Float => cast_numeric_to_float(v),
560        CastTarget::Bool => cast_to_bool(v),
561        // v7.40.0 — MySQL's CAST to a temporal type answers NULL, with a
562        // warning, for a value that is not a date. Measured on 9.7.2:
563        // `SELECT CAST('2020-99-99' AS DATE)` is NULL. The same value in
564        // an INSERT is `ERROR 1292` under STRICT_TRANS_TABLES, and that
565        // is a different path. PostgreSQL raises for both.
566        CastTarget::Date => mysql_null_on_bad_temporal(cast_to_date(v), mysql),
567        // TIMESTAMP and TIMESTAMPTZ share a runtime representation
568        // (i64 microseconds UTC) but NOT an input rule, and conflating
569        // the two silently stored the wrong instant. `::timestamp`
570        // keeps the wall clock a literal's offset was written against
571        // (round 289); `::timestamptz` CONVERTS by it. The evaluator's
572        // context-aware arm intercepts timestamptz before this point,
573        // so the difference only showed on the paths that reach here
574        // directly — INSERT VALUES folds its literals through
575        // `literal_expr_to_value_in`, and stored 10:00 for
576        // `'2020-01-01 10:00:00+02'::timestamptz` where PG stores
577        // 08:00 (round 310).
578        // v7.39 (round 423) — `CAST(x AS DATETIME)` / `AS TIMESTAMP` reach
579        // here as the dedicated variant rather than `Named`, so the MySQL
580        // "bare temporal type has fractional precision 0" rule has to be
581        // applied here too: MariaDB drops the fraction, PG keeps every
582        // microsecond.
583        CastTarget::Timestamp => {
584            let out = mysql_null_on_bad_temporal(cast_to_timestamp(v), mysql)?;
585            Ok(if mysql && !matches!(out, Value::Null) {
586                round_temporal_to_precision(out, 0, true)
587            } else {
588                out
589            })
590        }
591        CastTarget::Timestamptz => mysql_null_on_bad_temporal(cast_to_timestamptz(v), mysql),
592        // v7.9.25 — `expr::INTERVAL`. Currently only TEXT → Interval
593        // is supported (the mailrs idiom: `$1::INTERVAL` where the
594        // bound param is a string like `'7 days'`).
595        // v7.39 (round 544) — a time-of-day IS an interval of that
596        // length. Measured: '10:20:30.123456'::time::interval reads
597        // 10:20:30.123456 on PG18, fractional seconds and all.
598        CastTarget::Interval => match v {
599            Value::Time(us) => Ok(Value::Interval {
600                months: 0,
601                days: 0,
602                micros: us,
603                kind: spg_storage::IntervalKind::Finite,
604            }),
605            other => cast_to_interval(other),
606        },
607        // v7.9.25 — `::json` keeps the input text verbatim (PG's json
608        // type preserves whitespace / key order / duplicates).
609        CastTarget::Json => match v {
610            Value::Json(s) => Ok(Value::json(s)),
611            Value::Text(s) => Ok(Value::json(s)),
612            other => Err(EvalError::TypeMismatch {
613                detail: alloc::format!(
614                    "::json only accepts TEXT-shape inputs, got {}",
615                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
616                ),
617            }),
618        },
619        // v7.38 (read01) — `::jsonb` canonicalises like PG: object keys
620        // sorted (length, then bytes) + duplicates collapsed last-wins,
621        // `, ` / `: ` whitespace, and numbers normalised. Invalid JSON
622        // falls back to the verbatim text (validation stays a separate
623        // concern from this representation fix).
624        CastTarget::Jsonb => match v {
625            // v7.39 (read01 jsonb) — the explicit ::jsonb cast validates:
626            // invalid tokens (NaN / Infinity / malformed) error like PG
627            // instead of passing the raw text through.
628            Value::Json(s) | Value::Text(s) => match crate::json::canonicalize_jsonb(s.as_ref()) {
629                Ok(c) => Ok(Value::json(c)),
630                Err(_) => Err(EvalError::TypeMismatch {
631                    detail: alloc::string::String::from("invalid input syntax for type json"),
632                }),
633            },
634            other => Err(EvalError::TypeMismatch {
635                detail: alloc::format!(
636                    "::jsonb only accepts TEXT-shape inputs, got {}",
637                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
638                ),
639            }),
640        },
641        // v7.17.0 Phase 5.3 — `::regtype` / `::regclass`. PG
642        // semantics: each is a textual catalog-name surfacing as
643        // a numeric OID at the wire layer that renders back as
644        // the original name. SPG has no OID space, but pg_dump /
645        // mailrs / Django code uses the cast purely for textual
646        // round-trip — feeding `'public.t'::regclass::text` into
647        // a downstream `format(…)` or string concat. We map to
648        // that textual contract: Text in → Text out (the schema-
649        // qualifier `public.` is stripped to match PG's default
650        // search_path-aware rendering); numeric in → re-cast to
651        // Text as best-effort; anything else errors.
652        //
653        // Pre-3.3 / pre-5.3 (v7.9.26) the cast surfaced a clean
654        // error; this lifts to accept-and-textify so the dominant
655        // dump-loader pattern unblocks. SPG-shaped queries that
656        // genuinely need an OID for runtime joins are still
657        // documented as unsupported.
658        // v7.39 (round 694) — `'{text,int4}'::regtype[]`. PG canonicalises
659        // every ELEMENT (`int4` → `integer`) and rejects an unknown one, so
660        // the array runs the scalar's own name resolution per member rather
661        // than keeping the literal. `regclass[]` keeps its names — a
662        // relation name is already what PG prints.
663        CastTarget::Named(n) if n.eq_ignore_ascii_case("regtype_array") => {
664            let Value::Text(s) = &v else {
665                return Ok(v);
666            };
667            let body = s.trim();
668            let inner = body
669                .strip_prefix('{')
670                .and_then(|b| b.strip_suffix('}'))
671                .unwrap_or(body);
672            let mut out: Vec<Option<alloc::string::String>> = Vec::new();
673            for part in inner.split(',') {
674                let t = part.trim();
675                if t.is_empty() {
676                    continue;
677                }
678                if t.eq_ignore_ascii_case("NULL") {
679                    out.push(None);
680                    continue;
681                }
682                let bare = t.rsplit('.').next().unwrap_or(t);
683                match crate::conversions::regtype_canonical_name(bare) {
684                    Some(c) => out.push(Some(c)),
685                    None => {
686                        return Err(EvalError::TypeMismatch {
687                            detail: alloc::format!("type \"{t}\" does not exist"),
688                        });
689                    }
690                }
691            }
692            Ok(Value::TextArray(out))
693        }
694        CastTarget::RegType | CastTarget::RegClass => match v {
695            Value::Text(s) => {
696                // Strip an optional `<schema>.` prefix — PG's
697                // regclass render drops it when the schema is on
698                // the search_path; SPG is single-schema so
699                // dropping is always safe.
700                let bare = s.rsplit('.').next().unwrap_or(&s).to_string();
701                // v7.39 (read01 regproc.c) — regtype canonicalizes the
702                // name ('int4' → 'integer') and rejects unknown types
703                // (PG 42704).
704                if matches!(target, CastTarget::RegType) {
705                    // v7.39 (round 648) — carry the OID as well as the
706                    // name, the way `::regclass` and `::regproc` already
707                    // do. As a plain Text this rendered correctly and
708                    // then failed everything downstream: `'text'::regtype
709                    // ::oid` parsed the NAME as a number and answered
710                    // `invalid input syntax for type oid: "text"` where
711                    // PG answers 25, and `pg_typeof` said `text`.
712                    return match crate::conversions::regtype_canonical_name(&bare) {
713                        Some(c) => {
714                            let oid =
715                                crate::conversions::regtype_name_to_oid(&c.to_ascii_lowercase())
716                                    .unwrap_or(0);
717                            Ok(Value::RegType(oid, c.into_boxed_str()))
718                        }
719                        None => Err(EvalError::TypeMismatch {
720                            detail: alloc::format!("type \"{s}\" does not exist"),
721                        }),
722                    };
723                }
724                // 7.38.1 S5.1 — a CATALOG relation name folds to the
725                // dual (oid, name) value, so `'pg_amop'::regclass`
726                // compares with pg_depend's numeric classid and still
727                // renders as the name (PG's regclass IS an oid). User
728                // relations keep the textual round-trip contract.
729                if let Some((_, oid)) = crate::system_catalog::CATALOG_RELATIONS
730                    .iter()
731                    .find(|(n, _)| bare.eq_ignore_ascii_case(n))
732                {
733                    return Ok(Value::RegClass(*oid, bare.into_boxed_str()));
734                }
735                Ok(Value::text(bare))
736            }
737            // A numeric OID → its type name for `::regtype` (the common
738            // `atttypid::regtype` column-type-name shape). `::regclass`
739            // needs a catalog reverse-lookup for user relations, which
740            // this cast has no access to, so it keeps rendering the OID.
741            Value::Int(_) | Value::BigInt(_) => {
742                let n = match v {
743                    Value::Int(n) => i64::from(n),
744                    Value::BigInt(n) => n,
745                    _ => unreachable!(),
746                };
747                if matches!(target, CastTarget::RegType)
748                    && let Some(name) = crate::conversions::regtype_oid_to_name_owned(n)
749                {
750                    Ok(Value::RegType(n, name.into_boxed_str()))
751                } else {
752                    Ok(Value::text(alloc::format!("{n}")))
753                }
754            }
755            other => Err(EvalError::TypeMismatch {
756                detail: alloc::format!(
757                    "::regtype / ::regclass accepts TEXT (name) or integer (oid), got {}",
758                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
759                ),
760            }),
761        },
762        // v7.10.11 — `::TEXT[]`. Decode PG external array form
763        // when input is Text; pass through unchanged when it is
764        // already TextArray. Anything else is a type mismatch.
765        CastTarget::TextArray => match v {
766            Value::TextArray(items) => Ok(Value::TextArray(items)),
767            Value::Text(s) => {
768                if let Some(r) = try_cast_2d_array(&s, |row| {
769                    decode_text_array_external(row).map(Value::TextArray)
770                }) {
771                    return r;
772                }
773                decode_text_array_external(&s).map(Value::TextArray)
774            }
775            // Other scalar arrays cast element-wise, each element
776            // rendered as its own text (NULLs preserved). PG allows
777            // `ARRAY[1,2,3]::text[]`.
778            Value::IntArray(items) => Ok(Value::TextArray(
779                items
780                    .into_iter()
781                    .map(|o| o.map(|n| alloc::format!("{n}")))
782                    .collect(),
783            )),
784            Value::BigIntArray(items) => Ok(Value::TextArray(
785                items
786                    .into_iter()
787                    .map(|o| o.map(|n| alloc::format!("{n}")))
788                    .collect(),
789            )),
790            Value::SmallIntArray(items) => Ok(Value::TextArray(
791                items
792                    .into_iter()
793                    .map(|o| o.map(|n| alloc::format!("{n}")))
794                    .collect(),
795            )),
796            Value::BoolArray(items) => Ok(Value::TextArray(
797                items
798                    .into_iter()
799                    .map(|o| o.map(|b| String::from(if b { "t" } else { "f" })))
800                    .collect(),
801            )),
802            Value::FloatArray(items) => Ok(Value::TextArray(
803                items
804                    .into_iter()
805                    .map(|o| o.map(|x| value_to_text(&Value::Float(x))))
806                    .collect(),
807            )),
808            other => Err(EvalError::TypeMismatch {
809                detail: alloc::format!(
810                    "::TEXT[] only accepts TEXT / array inputs, got {}",
811                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
812                ),
813            }),
814        },
815        // v7.11.13 — `::INT[]` / `::BIGINT[]`. Decode PG external
816        // form `{1,2,3}` when input is Text; widen TextArray /
817        // IntArray as appropriate.
818        CastTarget::IntArray => cast_to_int_array(v),
819        CastTarget::BigIntArray => cast_to_bigint_array(v),
820        // v7.12.0 — `::tsvector` / `::tsquery`. Decodes PG external
821        // form when input is Text; passes through unchanged when the
822        // input is already the target type. Other inputs are a type
823        // mismatch. Lexer / Porter stemmer arrive in v7.12.1; the
824        // external-form cast at v7.12.0 is the path pg_dump and
825        // direct-literal callers use.
826        CastTarget::TsVector => match v {
827            Value::TsVector(items) => Ok(Value::TsVector(items)),
828            Value::Text(s) => decode_tsvector_external(&s).map(Value::TsVector),
829            other => Err(EvalError::TypeMismatch {
830                detail: alloc::format!(
831                    "::tsvector only accepts TEXT / tsvector inputs, got {}",
832                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
833                ),
834            }),
835        },
836        CastTarget::TsQuery => match v {
837            Value::TsQuery(ast) => Ok(Value::TsQuery(ast)),
838            Value::Text(s) => decode_tsquery_external(&s).map(Value::TsQuery),
839            other => Err(EvalError::TypeMismatch {
840                detail: alloc::format!(
841                    "::tsquery only accepts TEXT / tsquery inputs, got {}",
842                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
843                ),
844            }),
845        },
846        // v7.17.0 — `::uuid`. Identity for `uuid → uuid`; parse
847        // text via the shared `parse_uuid_str`. Anything else is a
848        // type mismatch — PG also rejects e.g. INT → UUID without
849        // an explicit text bridge.
850        CastTarget::Uuid => match v {
851            Value::Uuid(b) => Ok(Value::Uuid(b)),
852            Value::Text(s) => match spg_storage::parse_uuid_str(&s) {
853                Some(b) => Ok(Value::Uuid(b)),
854                None => Err(EvalError::TypeMismatch {
855                    detail: alloc::format!("invalid input syntax for type uuid: {s:?}"),
856                }),
857            },
858            other => Err(EvalError::TypeMismatch {
859                detail: alloc::format!(
860                    "::uuid only accepts TEXT / uuid inputs, got {}",
861                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
862                ),
863            }),
864        },
865        // v7.18 — `::bytea`. Identity for `Bytes → Bytes`; decode
866        // Text via the engine's PG-format bytea decoder (`\x`
867        // hex form + `\NNN` escape form). Anything else is a type
868        // mismatch — same shape as PG's contract. Closes the
869        // mailrs D-pre #3 reverse-acceptance gap.
870        CastTarget::Bytea => match v {
871            Value::Bytes(b) => Ok(Value::bytes(b)),
872            Value::Text(s) => match crate::conversions::decode_bytea_literal(&s) {
873                Ok(b) => Ok(Value::bytes(b)),
874                Err(msg) => Err(EvalError::TypeMismatch {
875                    detail: alloc::format!("invalid input syntax for type bytea: {msg}"),
876                }),
877            },
878            // v7.39 (round 544) — an integer's two's-complement bytes,
879            // big-endian, at the source type's width. Measured on PG18:
880            // 5::int2 -> \x0005, 5::int4 -> \x00000005,
881            // 5::int8 -> \x0000000000000005, (-1)::int4 -> \xffffffff.
882            Value::SmallInt(n) => Ok(Value::bytes(n.to_be_bytes().to_vec())),
883            Value::Int(n) => Ok(Value::bytes(n.to_be_bytes().to_vec())),
884            Value::BigInt(n) => Ok(Value::bytes(n.to_be_bytes().to_vec())),
885            other => Err(EvalError::TypeMismatch {
886                detail: alloc::format!(
887                    "::bytea only accepts TEXT / bytea / integer inputs, got {}",
888                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
889                ),
890            }),
891        },
892        CastTarget::Named(name) => {
893            // v7.39 (round 777, F31-E1) — a typmod'd ARRAY cast:
894            // `::numeric(3,1)[]` arrives as Named("numeric(3,1)_array")
895            // and fell through to the user-type lookup ('type
896            // "numeric(3,1)_array" does not exist'). PG applies the
897            // modifier per element ({1.5, 2.3}, measured). Cast to the
898            // bare base array first, then run every element through the
899            // scalar typmod cast.
900            if let Some(base_paren) = name.strip_suffix("_array")
901                && base_paren.ends_with(')')
902                && let Some(popen) = base_paren.find('(')
903            {
904                let base = &base_paren[..popen];
905                let arr = cast_value_ref_in(
906                    v,
907                    &CastTarget::Named(alloc::format!("{base}_array")),
908                    mysql,
909                )?;
910                let scalar = CastTarget::Named(alloc::string::String::from(base_paren));
911                return match arr {
912                    Value::NumericArray(items) => {
913                        let mut out = alloc::vec::Vec::with_capacity(items.len());
914                        for it in items {
915                            out.push(match it {
916                                None => None,
917                                Some((scaled, scale)) => {
918                                    match cast_value_ref_in(
919                                        Value::Numeric { scaled, scale, kind: spg_storage::NumericKind::Finite },
920                                        &scalar,
921                                        mysql,
922                                    )? {
923                                        Value::Numeric { scaled, scale, .. } => {
924                                            Some((scaled, scale))
925                                        }
926                                        Value::NumericBig(b) => {
927                                            return Err(EvalError::TypeMismatch {
928                                                detail: alloc::format!(
929                                                    "numeric value too large for {base_paren}[]: {b:?}"
930                                                ),
931                                            });
932                                        }
933                                        Value::Null => None,
934                                        other => {
935                                            return Err(EvalError::TypeMismatch {
936                                                detail: alloc::format!(
937                                                    "unexpected element cast result {other:?}"
938                                                ),
939                                            });
940                                        }
941                                    }
942                                }
943                            });
944                        }
945                        Ok(Value::NumericArray(out))
946                    }
947                    other => Ok(other),
948                };
949            }
950            // v7.39 (round 613) — a plain scalar spelling goes straight to
951            // the tail. See `PLAIN_NAMED_TARGETS` for why that is the same
952            // thing as walking the arm, and the pin for the check that says
953            // so mechanically.
954            if let Some(dt) = plain_named_target(name) {
955                return finish_named_cast(v, dt, name, None, mysql);
956            }
957            // v7.38 (read01) — a temporal type with a fractional-seconds
958            // precision (`time(3)`, `timestamp(0)`, `timestamptz(2)`) rounds the
959            // sub-second field to that many digits, like PG. Resolve against the
960            // base type (`type_name_to_data_type` does not know the `(N)` form)
961            // and round the coerced result below.
962            // v7.38 (read01, T20) — an integer casts to `bit(n)` as the low n
963            // bits of its two's-complement representation (PG; int→varbit is
964            // rejected there, so only fixed-length `bit` is handled here).
965            if matches!(v, Value::Int(_) | Value::BigInt(_) | Value::SmallInt(_)) {
966                if let Some(width) = bit_cast_width(name) {
967                    return int_to_bit_string(v, width.0);
968                }
969            }
970            // v7.39 (read01 varbit.c) — internal exact-length form for
971            // B'...' literals (an explicit ::bit means bit(1) below).
972            if name == "__bit_literal" {
973                return match &v {
974                    Value::Null => Ok(Value::Null),
975                    Value::Text(s) => match crate::conversions::parse_bit_string_text(s) {
976                        Some((nb, by)) => Ok(Value::bit_string(nb, by)),
977                        None => Err(EvalError::TypeMismatch {
978                            detail: alloc::format!("invalid input syntax for type bit: \"{s}\""),
979                        }),
980                    },
981                    Value::BitString { .. } => Ok(v),
982                    other => Err(EvalError::TypeMismatch {
983                        detail: alloc::format!(
984                            "cannot cast {} to bit",
985                            crate::conversions::pg_type_name_for_error_opt(other.data_type())
986                        ),
987                    }),
988                };
989            }
990            // v7.39 (read01 varbit.c) — `bit(n)` over a bit string (or a
991            // '0101' text form) zero-extends on the RIGHT or truncates to
992            // n (PG's bit() cast, unlike the input-time exact-length rule).
993            let bit_src: Option<Value<'static>> = match &v {
994                Value::BitString { .. } => Some(v.clone()),
995                Value::Text(s) if bit_cast_width(name).is_some() => {
996                    match crate::conversions::parse_bit_string_text(s) {
997                        Some((nb, by)) => Some(Value::bit_string(nb, by)),
998                        None => {
999                            let bad = s.chars().find(|c| *c != '0' && *c != '1');
1000                            return Err(EvalError::TypeMismatch {
1001                                detail: match bad {
1002                                    Some(c) => {
1003                                        alloc::format!("\"{c}\" is not a valid binary digit")
1004                                    }
1005                                    None => {
1006                                        alloc::format!("invalid input syntax for type bit: \"{s}\"")
1007                                    }
1008                                },
1009                            });
1010                        }
1011                    }
1012                }
1013                _ => None,
1014            };
1015            if let Some(Value::BitString { nbits, bytes }) = &bit_src {
1016                if let Some((width, pads)) = bit_cast_width(name) {
1017                    // varbit truncates but never pads.
1018                    if !pads && *nbits <= width {
1019                        return Ok(Value::BitString {
1020                            nbits: *nbits,
1021                            bytes: alloc::borrow::Cow::Owned(bytes.to_vec()),
1022                        });
1023                    }
1024                    let mut bits: alloc::vec::Vec<bool> = (0..*nbits as usize)
1025                        .map(|i| bytes[i / 8] & (0x80 >> (i % 8)) != 0)
1026                        .collect();
1027                    bits.resize(width as usize, false);
1028                    let mut out = alloc::vec![0u8; width.div_ceil(8) as usize];
1029                    for (i, b) in bits.iter().enumerate() {
1030                        if *b {
1031                            out[i / 8] |= 0x80 >> (i % 8);
1032                        }
1033                    }
1034                    return Ok(Value::BitString {
1035                        nbits: width,
1036                        bytes: alloc::borrow::Cow::Owned(out),
1037                    });
1038                }
1039            }
1040            // v7.39 (read01 oid.c) — OID is unsigned 32-bit: a negative
1041            // integer wraps (PG's (Oid) cast semantics: -1 -> 4294967295),
1042            // beyond u32 errors "OID out of range", bad text is 22P02.
1043            // v7.39 (read01 oid.c) — OID is unsigned 32-bit: a negative
1044            // integer wraps (PG's (Oid) cast semantics: -1 -> 4294967295),
1045            // beyond u32 errors "OID out of range", bad text is 22P02.
1046            //
1047            // Round 667 moved the rules to `conversions::coerce_to_oid` so
1048            // the column-assignment path shares them instead of growing a
1049            // second copy.
1050            if name.eq_ignore_ascii_case("oid")
1051                && let Some(out) = crate::conversions::coerce_to_oid(&v)?
1052            {
1053                return Ok(out);
1054            }
1055            // v7.39 (read01 mac8.c) — macaddr8 -> macaddr requires the
1056            // EUI-64 ff:fe infix; anything else is PG's dedicated error.
1057            if name.eq_ignore_ascii_case("macaddr") {
1058                if let Value::Macaddr8(b) = &v {
1059                    if b[3] == 0xff && b[4] == 0xfe {
1060                        return Ok(Value::Macaddr([b[0], b[1], b[2], b[5], b[6], b[7]]));
1061                    }
1062                    return Err(EvalError::TypeMismatch {
1063                        detail: "macaddr8 data out of range to convert to macaddr".into(),
1064                    });
1065                }
1066            }
1067            // v7.39 (read01 regproc.c) — the remaining reg* input types.
1068            // SPG carries them as their canonical text rendering; name
1069            // resolution runs against the static pg_proc table / the FTS
1070            // configuration list.
1071            // v7.39 (round 607) — matched against the static list rather than
1072            // through an owned lowercase copy. The copy was built for every
1073            // row and thrown away on every row that is not one of these.
1074            if let Some(lower_name) = REG_MISC_TYPES
1075                .iter()
1076                .copied()
1077                .find(|k| name.eq_ignore_ascii_case(k))
1078            {
1079                let s = match &v {
1080                    Value::Null => return Ok(Value::Null),
1081                    Value::Text(s) => s.as_ref().trim().to_string(),
1082                    // v7.39 (round 634) — an OID reaches these types too.
1083                    // PG registers int2/int4/int8/oid -> regproc as IMPLICIT
1084                    // casts and renders an oid with no matching entry as the
1085                    // number itself: `1::INT::REGPROC` is `1`, and
1086                    // `1247::OID::REGPROC` is `1247`. SPG refused the whole
1087                    // integer family with "accepts TEXT".
1088                    Value::SmallInt(n) => return Ok(Value::text(n.to_string())),
1089                    Value::Int(n) => return Ok(Value::text(n.to_string())),
1090                    Value::BigInt(n) => return Ok(Value::text(n.to_string())),
1091                    other => {
1092                        return Err(EvalError::TypeMismatch {
1093                            detail: alloc::format!(
1094                                "::{lower_name} accepts TEXT, got {}",
1095                                crate::conversions::pg_type_name_for_error_opt(other.data_type())
1096                            ),
1097                        });
1098                    }
1099                };
1100                return cast_reg_misc(lower_name, &s);
1101            }
1102            // v7.39 (round 514) — the remaining catalog-shaped types. Each
1103            // validates its own text form and keeps it, which is what PG's
1104            // input functions do; the wordings below are PG18 readings.
1105            if let Some(out) = cast_catalog_scalar(name, &v)? {
1106                return Ok(out);
1107            }
1108            // v7.39 (round 511) — `'(0,1)'::tid`, so a caller can name a row
1109            // it read a ctid from earlier. PG's text form is the only input
1110            // shape it has.
1111            if name.eq_ignore_ascii_case("tid") {
1112                return match &v {
1113                    Value::Tid(..) => Ok(v),
1114                    Value::Text(t) => parse_tid_text(t).ok_or_else(|| EvalError::TypeMismatch {
1115                        detail: alloc::format!("invalid input syntax for type tid: \"{t}\""),
1116                    }),
1117                    other => Err(EvalError::TypeMismatch {
1118                        detail: alloc::format!(
1119                            "cannot cast type {} to tid",
1120                            crate::eval::strings::pg_typeof_name(other)
1121                        ),
1122                    }),
1123                };
1124            }
1125            // v7.39 (read01 pseudotypes.c) — casting a value INTO a
1126            // pseudotype hits PG's dummy input functions (0A000).
1127            if let Some(lower) = OPAQUE_TYPES
1128                .iter()
1129                .copied()
1130                .find(|k| name.eq_ignore_ascii_case(k))
1131            {
1132                // v7.39 (round 509) — a pseudotype is a REAL type name,
1133                // so `NULL::anyarray` is NULL on PG, not an error. Only a
1134                // VALUE hits the dummy input function. Before this the
1135                // NULL case fell through to the type table below, which
1136                // does not carry the pseudotypes, and once NULL stopped
1137                // short-circuiting the whole cast it started reporting
1138                // them as unknown types.
1139                return if matches!(v, Value::Null) {
1140                    Ok(Value::Null)
1141                } else {
1142                    Err(EvalError::TypeMismatch {
1143                        detail: alloc::format!("cannot accept a value of type {lower}"),
1144                    })
1145                };
1146            }
1147            // v7.39 (read01 pseudotypes.c) — `::cstring` is PG's I/O-form
1148            // pseudotype: text in, text out (cstring_in/out are identity).
1149            // SPG carries it as text; pg_typeof(cstring) reading "text" is
1150            // a recorded delta (RD-1) alongside the literal projection OIDs.
1151            // v7.39 (read01 xid8funcs.c) — `::xid` (32-bit, wrapping) and
1152            // `::xid8` (64-bit, full) parse an integer text and render it
1153            // back verbatim. SPG carries them as BigInt.
1154            if name.eq_ignore_ascii_case("xid") || name.eq_ignore_ascii_case("xid8") {
1155                return Ok(match v {
1156                    Value::Null => Value::Null,
1157                    // v7.38.19 (RD-11) — PostgreSQL refuses EVERY integer
1158                    // type here, for both names, and SPG accepted them.
1159                    // Measured on PG 18.4:
1160                    //
1161                    //   SELECT 1::xid8            cannot cast type integer to xid8
1162                    //   SELECT 1::bigint::xid8    cannot cast type bigint to xid8
1163                    //   SELECT 1::bigint::xid     cannot cast type bigint to xid
1164                    //   SELECT '1'::text::xid8    1
1165                    //
1166                    // Only the text form is a cast; the integer form is
1167                    // not, because a transaction id is not a number you
1168                    // may arrive at by arithmetic.
1169                    //
1170                    // This is the direction that matters most: SPG
1171                    // accepting what PG rejects means code PG would have
1172                    // stopped runs here, and the difference surfaces
1173                    // somewhere else, later. It was found by re-measuring
1174                    // the recorded delta two lines above -- which had
1175                    // never mentioned it.
1176                    Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) => {
1177                        return Err(EvalError::TypeMismatch {
1178                            detail: alloc::format!(
1179                                "cannot cast type {} to {}",
1180                                match v {
1181                                    Value::SmallInt(_) => "smallint",
1182                                    Value::Int(_) => "integer",
1183                                    _ => "bigint",
1184                                },
1185                                name.to_ascii_lowercase()
1186                            ),
1187                        });
1188                    }
1189                    Value::Text(s) => {
1190                        let t = s.trim();
1191                        match t.parse::<u64>() {
1192                            Ok(n) => Value::BigInt(n as i64),
1193                            Err(_) => {
1194                                return Err(EvalError::TypeMismatch {
1195                                    detail: alloc::format!(
1196                                        "invalid input syntax for type {}: \"{s}\"",
1197                                        name.to_ascii_lowercase()
1198                                    ),
1199                                });
1200                            }
1201                        }
1202                    }
1203                    other => {
1204                        return Err(EvalError::TypeMismatch {
1205                            detail: alloc::format!(
1206                                "cannot cast {} to {name}",
1207                                crate::conversions::pg_type_name_for_error_opt(other.data_type())
1208                            ),
1209                        });
1210                    }
1211                });
1212            }
1213            // v7.39 (read01 varchar.c) — `::name` is text truncated to
1214            // NAMEDATALEN-1 (63) bytes.
1215            if name.eq_ignore_ascii_case("name") {
1216                return Ok(match v {
1217                    Value::Null => Value::Null,
1218                    other => {
1219                        let t = match other {
1220                            Value::Text(s) => s.into_owned(),
1221                            o => value_to_text(&o),
1222                        };
1223                        let mut cut = t;
1224                        if cut.len() > 63 {
1225                            let mut idx = 63;
1226                            while !cut.is_char_boundary(idx) {
1227                                idx -= 1;
1228                            }
1229                            cut.truncate(idx);
1230                        }
1231                        Value::text(cut)
1232                    }
1233                });
1234            }
1235            if name.eq_ignore_ascii_case("cstring") {
1236                return Ok(match v {
1237                    Value::Null => Value::Null,
1238                    Value::Text(s) => Value::Text(s),
1239                    other => Value::text(value_to_text(&other)),
1240                });
1241            }
1242            // v7.39 (read01 jsonpath.c) — `::jsonpath` parses and prints
1243            // the canonical form (PG's jsonpath type; SPG carries it as
1244            // text — the wire OID is a recorded residual with the other
1245            // literal projection OIDs).
1246            if name.eq_ignore_ascii_case("jsonpath") {
1247                return match v {
1248                    Value::Null => Ok(Value::Null),
1249                    Value::Text(s) => Ok(Value::text(crate::json::jsonpath_canonical(s.as_ref())?)),
1250                    other => Err(EvalError::TypeMismatch {
1251                        detail: alloc::format!(
1252                            "cannot cast {} to jsonpath",
1253                            crate::conversions::pg_type_name_for_error_opt(other.data_type())
1254                        ),
1255                    }),
1256                };
1257            }
1258            // v7.39 (round 355, M13) — MySQL's `BINARY` / `BINARY(n)`.
1259            // It is a COLLATION coercion, not a type change: MariaDB
1260            // renders `BINARY 'abc'` as `abc` (and `HEX()` of it as
1261            // 616263), so the value passes through unchanged; `(n)`
1262            // truncates to n bytes (`CAST('abc' AS BINARY(2))` is `ab`,
1263            // measured). What it really buys is byte-wise comparison,
1264            // which `compare_is_case_insensitive` now refuses to fold.
1265            if mysql
1266                && (name.eq_ignore_ascii_case("binary")
1267                    || name.to_ascii_lowercase().starts_with("binary("))
1268            {
1269                return cast_mysql_binary(v, name);
1270            }
1271            // v7.39 (round 352, M8) — MySQL's SIGNED / UNSIGNED targets.
1272            // Measured on MariaDB 11: a string gives its LEADING number
1273            // (`'12abc'` → 12, `'abc'` → 0); a fractional value ROUNDS
1274            // half-away-from-zero (1.5 → 2, 2.5 → 3, -2.5 → -3) rather
1275            // than truncating; and UNSIGNED wraps a negative through u64
1276            // (`-1` → 18446744073709551615).
1277            // PG has no such type — `type "signed" does not exist` — so the
1278            // reading is gated on the dialect, not just on the spelling.
1279            if mysql
1280                && (name.eq_ignore_ascii_case("signed") || name.eq_ignore_ascii_case("unsigned"))
1281            {
1282                return cast_mysql_integer(v, name.eq_ignore_ascii_case("unsigned"));
1283            }
1284            // v7.39 (round 423) — a bare MySQL temporal type carries
1285            // fractional precision 0, so `CAST(x AS DATETIME)` drops the
1286            // fraction (measured on MariaDB 11). PG's `::timestamp` keeps
1287            // every microsecond, so the default is dialect-gated.
1288            let temporal_prec = temporal_typmod(name)
1289                .or_else(|| (mysql && is_bare_temporal_type(name)).then_some(0));
1290            let resolve_name: alloc::borrow::Cow<'_, str> = if temporal_prec.is_some() {
1291                alloc::borrow::Cow::Owned(name.split('(').next().unwrap_or(name).trim().to_string())
1292            } else {
1293                alloc::borrow::Cow::Borrowed(name.as_str())
1294            };
1295            // v7.37.5 ship triage — generic typed-cast dispatch.
1296            // Resolve the ident to a `DataType` and route the value
1297            // through the existing `coerce_value` text-decoder for
1298            // every v7.37.5 γ/δ/ε/ζ-A type that already speaks
1299            // Text→typed via codec.
1300            let dt =
1301                crate::conversions::type_name_to_data_type(&resolve_name).ok_or_else(|| {
1302                    // v7.39 (round 272) — a numeric typmod outside PG's
1303                    // bounds gets PG's own wording rather than being
1304                    // reported as an unknown type.
1305                    // v7.39 (round 620) — and an unknown one is PG's
1306                    // wording, which also earns it PG's SQLSTATE (42704
1307                    // UNDEFINED_OBJECT; `unsupported cast target` fell
1308                    // through to the generic 42000).
1309                    EvalError::TypeMismatch {
1310                        detail: crate::conversions::numeric_typmod_error(&resolve_name)
1311                            .unwrap_or_else(|| unknown_type_error_text(name)),
1312                    }
1313                })?;
1314            finish_named_cast(v, dt, &resolve_name, temporal_prec, mysql)
1315        }
1316    }
1317}
1318
1319/// v7.39 (round 613) — the tail of the `Named` arm: stringify for the text
1320/// targets, coerce, and round a temporal precision. Split out so the fast
1321/// path below reaches exactly this code rather than a copy of it.
1322/// v7.39 (round 722) — the compiled `Step::CastPlain` entry: same tail,
1323/// name pre-resolved at compile time.
1324/// v7.40.0 — MySQL turns a failed temporal cast into NULL; PostgreSQL
1325/// raises. Applied only where the failure IS the value not being a date
1326/// — an error from anywhere else passes through.
1327fn mysql_null_on_bad_temporal(
1328    r: Result<Value<'static>, EvalError>,
1329    mysql: bool,
1330) -> Result<Value<'static>, EvalError> {
1331    match r {
1332        Ok(v) => Ok(v),
1333        Err(_) if mysql => Ok(Value::Null),
1334        Err(e) => Err(e),
1335    }
1336}
1337
1338pub(crate) fn finish_named_cast_plain(
1339    v: Value<'static>,
1340    dt: spg_storage::DataType,
1341    resolve_name: &str,
1342    mysql: bool,
1343) -> Result<Value<'static>, EvalError> {
1344    finish_named_cast(v, dt, resolve_name, None, mysql)
1345}
1346
1347fn finish_named_cast(
1348    v: Value<'static>,
1349    dt: spg_storage::DataType,
1350    resolve_name: &str,
1351    temporal_prec: Option<u8>,
1352    mysql: bool,
1353) -> Result<Value<'static>, EvalError> {
1354    // PG semantics: any value casts to varchar(n) / char(n) through its text
1355    // representation (`99::char(2)` → '99'), and an EXPLICIT cast truncates
1356    // to n characters — only column assignment errors on overflow. Stringify
1357    // a non-text source first, then truncate up front so the coerce path's
1358    // length contract never fires here.
1359    let v = match (&dt, v) {
1360        // v7.38 (read01) — an explicit cast to TEXT stringifies any
1361        // value (`text(42)` → '42'), matching `42::text`. (coerce_value
1362        // deliberately rejects a bare INT→TEXT so INSERT stays strict.)
1363        (spg_storage::DataType::Text, v) => match v {
1364            Value::Text(s) => Value::Text(s),
1365            // v7.39 (read01 inet family) — inet/cidr ::text carries
1366            // the mask even at full length (cast-path form).
1367            Value::Inet { family, bits, addr } | Value::Cidr { family, bits, addr } => {
1368                Value::text(crate::conversions::format_inet_full(family, bits, &addr))
1369            }
1370            other => Value::text(value_to_text(&other)),
1371        },
1372        (spg_storage::DataType::Varchar(n) | spg_storage::DataType::Char(n), v) => {
1373            // v7.37 D.36 — previously only `Value::Text` was handled, so
1374            // `99::char(2)` reached coerce_value as an INT and hit a
1375            // CHAR/INT storage type-mismatch.
1376            // v7.39 (bpchar epic) — a bpchar source enters through its
1377            // text cast (trailing blanks stripped): `::varchar` keeps
1378            // the stripped form, `::char(m)` re-pads in coerce_value.
1379            let s = match v {
1380                Value::Text(s) => s.into_owned(),
1381                Value::BpChar(s) => s.trim_end_matches(' ').to_string(),
1382                other => value_to_text(&other),
1383            };
1384            let s = if *n > 0 && s.chars().count() > *n as usize {
1385                s.chars()
1386                    .take(*n as usize)
1387                    .collect::<alloc::string::String>()
1388            } else {
1389                s
1390            };
1391            Value::text(s)
1392        }
1393        (_, v) => v,
1394    };
1395    // v7.40.0 — MySQL's CAST to a temporal type answers NULL, with a
1396    // warning, where the value is not a date. Measured on 9.7.2:
1397    // `SELECT CAST('2020-99-99' AS DATE)` is NULL; the same value in an
1398    // INSERT is `ERROR 1292` under STRICT_TRANS_TABLES, and that path
1399    // does not come through here.
1400    //
1401    // PostgreSQL raises for both, and keeps doing so.
1402    let temporal_target = matches!(
1403        dt,
1404        spg_storage::DataType::Date
1405            | spg_storage::DataType::Time
1406            | spg_storage::DataType::TimeTz
1407            | spg_storage::DataType::Timestamp
1408            | spg_storage::DataType::Timestamptz
1409    );
1410    if mysql && temporal_target {
1411        let Ok(ok) = crate::conversions::coerce_value(v, dt, resolve_name, 0) else {
1412            return Ok(Value::Null);
1413        };
1414        return Ok(match temporal_prec {
1415            Some(prec) => round_temporal_to_precision(ok, prec, mysql),
1416            None => ok,
1417        });
1418    }
1419    let coerced =
1420        crate::conversions::coerce_value(v, dt, resolve_name, 0).map_err(|e| match e {
1421            // v7.39 (read01 round 113) — pass an already-classed engine
1422            // error through unchanged. Re-stringifying via Display would
1423            // double the "eval: type mismatch: " class prefix (the wire
1424            // strips only the outermost one), leaking it into the message
1425            // — visible now that jsonb → numeric casts error with PG's
1426            // exact "cannot cast jsonb string to type numeric" wording.
1427            crate::EngineError::Eval(ev) => ev,
1428            // v7.39 (round 622, S05a) — `coerce_value` is the INSERT-time
1429            // COLUMN coercion, and a cast borrows it. Its rejection is
1430            // phrased for a column, so `SELECT 1::INET` answered
1431            //
1432            //   type mismatch in column "inet" (position 0): expected INET,
1433            //   got INT
1434            //
1435            // naming a column that does not exist, at a position that means
1436            // nothing, in the storage layer's own vocabulary. PG says
1437            // `cannot cast type integer to inet`. The column phrasing stays
1438            // where it belongs — an INSERT still says which column — and a
1439            // failed cast now says what it failed to cast, like every other
1440            // arm in this file already did.
1441            //
1442            // The two type names come off the error itself, which already
1443            // carries them as `DataType`. Naming them BEFORE the call — the
1444            // obvious way to write this, since the value and the target both
1445            // move into it — costs two `String`s on every SUCCESSFUL cast,
1446            // and the panel caught exactly that: `id::NUMERIC` 23.75 ->
1447            // 55.48 ms, `id::REAL` 21.55 -> 50.48. This is the same eager
1448            // error construction round 614 removed from 28 call sites,
1449            // rebuilt by hand a round later.
1450            crate::EngineError::Storage(spg_storage::StorageError::TypeMismatch {
1451                expected,
1452                actual,
1453                ..
1454            }) => EvalError::TypeMismatch {
1455                detail: alloc::format!(
1456                    "cannot cast {} to {}",
1457                    crate::conversions::pg_type_name_for_error(actual),
1458                    crate::conversions::pg_type_name_for_error(expected)
1459                ),
1460            },
1461            other => EvalError::TypeMismatch {
1462                detail: alloc::format!("{other}"),
1463            },
1464        })?;
1465    Ok(match temporal_prec {
1466        Some(prec) => round_temporal_to_precision(coerced, prec, mysql),
1467        None => coerced,
1468    })
1469}
1470
1471/// v7.39 (round 613) — the plain scalar spellings, with the type each one
1472/// resolves to.
1473///
1474/// Round 612 measured the `Named` arm re-deriving everything for every row:
1475/// `s::VARCHAR` cost 30.6 ms over 200k rows where `s::TEXT` — the identical
1476/// conversion, under a spelling the parser settles into a `CastTarget`
1477/// variant — cost 11.2, and probes split the difference across the whole arm
1478/// rather than any one place in it. These names reach the tail directly.
1479///
1480/// Both halves of that shortcut are checked mechanically by the pin, not by
1481/// eye: every entry's type is asserted to equal `type_name_to_data_type`'s
1482/// answer, and every entry is asserted absent from each arm above the
1483/// resolve (the reg-misc / catalog-scalar / opaque lists, `tid`, `xid`,
1484/// `xid8`, `jsonpath`, the MySQL `binary` / `signed` / `unsigned` names, and
1485/// the bit and temporal spellings). A name that grows a special case has to
1486/// leave this table, and the pin says so.
1487const PLAIN_NAMED_TARGETS: &[(&str, spg_storage::DataType)] = &[
1488    ("text", spg_storage::DataType::Text),
1489    ("varchar", spg_storage::DataType::Varchar(0)),
1490    ("character varying", spg_storage::DataType::Varchar(0)),
1491    (
1492        "numeric",
1493        spg_storage::DataType::Numeric {
1494            precision: 0,
1495            scale: 0,
1496        },
1497    ),
1498    (
1499        "decimal",
1500        spg_storage::DataType::Numeric {
1501            precision: 0,
1502            scale: 0,
1503        },
1504    ),
1505    ("real", spg_storage::DataType::Real),
1506    ("float4", spg_storage::DataType::Real),
1507    ("float8", spg_storage::DataType::Float),
1508    ("double precision", spg_storage::DataType::Float),
1509    ("int2", spg_storage::DataType::SmallInt),
1510    ("smallint", spg_storage::DataType::SmallInt),
1511    ("int4", spg_storage::DataType::Int),
1512    ("integer", spg_storage::DataType::Int),
1513    ("int8", spg_storage::DataType::BigInt),
1514    ("bool", spg_storage::DataType::Bool),
1515    ("boolean", spg_storage::DataType::Bool),
1516    ("date", spg_storage::DataType::Date),
1517    ("bytea", spg_storage::DataType::Bytes),
1518    ("uuid", spg_storage::DataType::Uuid),
1519];
1520
1521/// v7.39 (round 613) — the heads that may carry a typmod and are still
1522/// plain: `varchar(20)`, `char(4)`, `numeric(10,2)`. The type comes from
1523/// `type_name_to_data_type` over the WHOLE name, so the typmod is parsed
1524/// exactly where it always was; only the walk down the arm is skipped. The
1525/// pin checks each head against every arm above the resolve, and that none
1526/// of them is a bit or temporal spelling.
1527pub(crate) const PLAIN_NAMED_HEADS: &[&str] = &[
1528    "varchar",
1529    "character varying",
1530    "char",
1531    "character",
1532    "bpchar",
1533    "numeric",
1534    "decimal",
1535];
1536
1537/// The type a plain scalar spelling resolves to, or `None` when the name
1538/// needs the whole arm.
1539pub(crate) fn plain_named_target(name: &str) -> Option<spg_storage::DataType> {
1540    if let Some(dt) = PLAIN_NAMED_TARGETS
1541        .iter()
1542        .find(|(k, _)| name.eq_ignore_ascii_case(k))
1543        .map(|(_, dt)| *dt)
1544    {
1545        return Some(dt);
1546    }
1547    let head = name.split('(').next()?.trim();
1548    if name.len() == head.len()
1549        || !PLAIN_NAMED_HEADS
1550            .iter()
1551            .any(|k| head.eq_ignore_ascii_case(k))
1552    {
1553        return None;
1554    }
1555    crate::conversions::type_name_to_data_type(name)
1556}
1557
1558/// The scalar type names the `Named` arm resolves without a catalog.
1559fn is_known_scalar_name(lower: &str) -> bool {
1560    REG_MISC_TYPES.contains(&lower)
1561        || CATALOG_SCALAR_TYPES.contains(&lower)
1562        || OPAQUE_TYPES.contains(&lower)
1563        || matches!(
1564            lower,
1565            "tid"
1566                | "record"
1567                | "cstring"
1568                | "regnamespace"
1569                | "regrole"
1570                // Round 896 — the target validator runs before the arm, so
1571                // the quoted spelling has to be a known name here too or it
1572                // is rejected before the fold above ever sees it.
1573                | "regclass"
1574                | "regtype"
1575        )
1576}
1577
1578/// v7.39 (round 509) — does this name a type at all?
1579///
1580/// PG validates the cast TARGET whatever the operand is: `NULL::nosuchtype`
1581/// is an error there, not NULL. `cast_value_in` short-circuits a NULL before
1582/// it ever looks at the target, so the check has to happen in the caller —
1583/// and `eval_cast_arm` is the caller that has a catalog, which is what
1584/// enums, domains, composites and table row types need.
1585///
1586/// This lists what the `Named` arm below resolves WITHOUT a catalog. Keeping
1587/// the two in step is a real hazard: a first cut of this check missed three
1588/// live spellings — `::binary` (the MySQL prefix's desugar), a table's row
1589/// type, and the pseudotypes — and the e2e suite caught every one. It is the
1590/// check on this function.
1591/// v7.39 (round 620) — PG's wording for a cast target that names no type.
1592///
1593/// SPG said ``unsupported cast target `::nosuchtype` ``, which reads as "SPG
1594/// has not got round to that one" when what happened is that no such type
1595/// exists anywhere. PG says `type "nosuchtype" does not exist`, and because
1596/// the wire classifies by message text, saying it also moves the code off the
1597/// generic 42000 onto 42704 UNDEFINED_OBJECT.
1598pub(crate) fn unknown_type_error_text(name: &str) -> alloc::string::String {
1599    alloc::format!("type \"{name}\" does not exist")
1600}
1601
1602pub(crate) fn builtin_target_resolves(name: &str, mysql: bool) -> bool {
1603    if name == "__bit_literal" || bit_cast_width(name).is_some() {
1604        return true;
1605    }
1606    crate::conversions::with_lower_name(name, |lower| {
1607        builtin_target_resolves_lower(name, lower, mysql)
1608    })
1609}
1610
1611fn builtin_target_resolves_lower(name: &str, lower: &str, mysql: bool) -> bool {
1612    // The three families, read from the same declarations the value path
1613    // dispatches on — see their doc comment for why that matters.
1614    if is_known_scalar_name(lower) {
1615        return true;
1616    }
1617    // v7.39 (round 515) — `<element>[]`, which this parser names
1618    // `<element>_array`. PG has an array type for every scalar, so the rule
1619    // is the stem's: `NULL::cstring[]`, `NULL::aclitem[]` and
1620    // `NULL::"char"[]` all resolve there. A general rule rather than three
1621    // entries, because the next scalar added would otherwise need a fourth.
1622    if let Some(stem) = lower.strip_suffix("_array")
1623        && (is_known_scalar_name(stem)
1624            || crate::conversions::type_name_to_data_type(stem).is_some())
1625    {
1626        return true;
1627    }
1628    if mysql && matches!(lower, "binary" | "signed" | "unsigned") {
1629        return true;
1630    }
1631    let base = if temporal_typmod(name).is_some() || (mysql && is_bare_temporal_type(name)) {
1632        name.split('(').next().unwrap_or(name).trim()
1633    } else {
1634        name
1635    };
1636    crate::conversions::type_name_to_data_type(base).is_some()
1637        || crate::conversions::numeric_typmod_error(base).is_some()
1638}
1639
1640/// v7.39 (round 511) — PG's `(block,offset)` text form for a tid.
1641fn parse_tid_text(t: &str) -> Option<Value<'static>> {
1642    let inner = t.trim().strip_prefix('(')?.strip_suffix(')')?;
1643    let (b, o) = inner.split_once(',')?;
1644    Some(Value::Tid(
1645        b.trim().parse::<u32>().ok()?,
1646        o.trim().parse::<u32>().ok()?,
1647    ))
1648}
1649
1650/// v7.39 (round 514) — the catalog-shaped scalar types: the ids, the oid
1651/// vectors, an ACL item, a cursor name and a transaction snapshot.
1652///
1653/// `Some` when `name` is one of them, so the caller can fall through to
1654/// everything else. Every error wording is a PG18 reading — they differ per
1655/// type and per ELEMENT (`::oidvector` complains about `oid`,
1656/// `::int2vector` about `smallint`), which is why they are spelled out
1657/// rather than shared.
1658fn cast_catalog_scalar(name: &str, v: &Value<'_>) -> Result<Option<Value<'static>>, EvalError> {
1659    crate::conversions::with_lower_name(name, |lower| cast_catalog_scalar_lower(lower, v))
1660}
1661
1662fn cast_catalog_scalar_lower(
1663    lower: &str,
1664    v: &Value<'_>,
1665) -> Result<Option<Value<'static>>, EvalError> {
1666    // v7.39 (round 515) — `<element>[]` runs the element's own check over
1667    // each member and keeps the literal, which is what PG does: measured,
1668    // `'{a,b}'::aclitem[]` is "unrecognized key word: \"a\"".
1669    if let Some(stem) = lower.strip_suffix("_array")
1670        && (CATALOG_SCALAR_TYPES.contains(&stem) || OPAQUE_TYPES.contains(&stem))
1671    {
1672        let Value::Text(t) = v else {
1673            return Ok(None);
1674        };
1675        let body = t.trim();
1676        let inner = body
1677            .strip_prefix('{')
1678            .and_then(|b| b.strip_suffix('}'))
1679            .unwrap_or(body);
1680        for part in inner.split(',').filter(|p| !p.trim().is_empty()) {
1681            cast_catalog_scalar(stem, &Value::text(part.trim().to_string()))?;
1682        }
1683        return Ok(Some(Value::text(body.to_string())));
1684    }
1685    if !CATALOG_SCALAR_TYPES.contains(&lower) {
1686        return Ok(None);
1687    }
1688    let text = match v {
1689        Value::Text(t) => t.to_string(),
1690        Value::Cid(c) if lower == "cid" => return Ok(Some(Value::Cid(*c))),
1691        Value::Xid(x) if lower == "xid" => return Ok(Some(Value::Xid(*x))),
1692        // v7.39 (round 641) — PG has no cast between an integer and a
1693        // transaction id in either direction: `5::xid` is "cannot cast
1694        // type integer to xid" and `'5'::xid::int` is the mirror of it,
1695        // measured. The unknown-literal spelling `'5'::xid` is a
1696        // different thing — that is the type's input function, and it is
1697        // the Text arm above. Only `xid` is carved out here; `cid`,
1698        // `oid` and the vector types keep taking an integer.
1699        Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_) if lower == "xid" => {
1700            return Err(EvalError::TypeMismatch {
1701                detail: alloc::format!(
1702                    "cannot cast type {} to xid",
1703                    crate::eval::strings::pg_typeof_name(v)
1704                ),
1705            });
1706        }
1707        Value::SmallInt(n) => alloc::format!("{n}"),
1708        Value::Int(n) => alloc::format!("{n}"),
1709        Value::BigInt(n) => alloc::format!("{n}"),
1710        other => {
1711            return Err(EvalError::TypeMismatch {
1712                detail: alloc::format!(
1713                    "cannot cast type {} to {lower}",
1714                    crate::eval::strings::pg_typeof_name(other)
1715                ),
1716            });
1717        }
1718    };
1719    let t = text.trim();
1720    let bad = |ty: &str, what: &str| EvalError::TypeMismatch {
1721        detail: alloc::format!("invalid input syntax for type {ty}: \"{what}\""),
1722    };
1723    let out = match lower {
1724        "cid" => Value::Cid(t.parse::<u32>().map_err(|_| bad("cid", t))?),
1725        "xid" => Value::Xid(t.parse::<u32>().map_err(|_| bad("xid", t))?),
1726        // Space-separated element lists, validated element by element and
1727        // kept in their own spelling.
1728        "oidvector" | "int2vector" => {
1729            let elem_ty = if lower == "oidvector" {
1730                "oid"
1731            } else {
1732                "smallint"
1733            };
1734            for part in t.split_whitespace() {
1735                let ok = if elem_ty == "oid" {
1736                    part.parse::<u32>().is_ok()
1737                } else {
1738                    part.parse::<i16>().is_ok()
1739                };
1740                if !ok {
1741                    return Err(bad(elem_ty, part));
1742                }
1743            }
1744            // v7.39.11 — a real vector, not the text that happens to
1745            // print the same. Through 7.39.10 this was `Value::text`,
1746            // and every array operation over a catalog vector raised.
1747            if elem_ty == "oid" {
1748                Value::OidVector(
1749                    t.split_whitespace()
1750                        .map(|p| p.parse::<u32>().unwrap_or_default())
1751                        .collect(),
1752                )
1753            } else {
1754                Value::Int2Vector(
1755                    t.split_whitespace()
1756                        .map(|p| p.parse::<i16>().unwrap_or_default())
1757                        .collect(),
1758                )
1759            }
1760        }
1761        // `grantee=privileges/grantor`, and PG checks the key word first:
1762        // anything before the `=` that is not a role name, `group` or
1763        // `user` is "unrecognized key word".
1764        "aclitem" => {
1765            let Some((who, rest)) = t.split_once('=') else {
1766                return Err(EvalError::TypeMismatch {
1767                    detail: alloc::format!("unrecognized key word: \"{t}\""),
1768                });
1769            };
1770            if !rest.contains('/') {
1771                return Err(EvalError::TypeMismatch {
1772                    detail: alloc::format!("a name must follow the \"/\" sign"),
1773                });
1774            }
1775            let _ = who;
1776            Value::text(t.to_string())
1777        }
1778        // A cursor name is just a name.
1779        "refcursor" => Value::text(t.to_string()),
1780        // `xmin:xmax:xip_list` — two numbers and a comma-separated tail.
1781        "pg_snapshot" | "txid_snapshot" => {
1782            let parts: alloc::vec::Vec<&str> = t.splitn(3, ':').collect();
1783            let shaped = parts.len() == 3
1784                && parts[0].parse::<u64>().is_ok()
1785                && parts[1].parse::<u64>().is_ok()
1786                && (parts[2].is_empty() || parts[2].split(',').all(|x| x.parse::<u64>().is_ok()));
1787            if !shaped {
1788                return Err(bad(lower, t));
1789            }
1790            Value::text(t.to_string())
1791        }
1792        // PG normalises a path on input: `$.a` reads back `$."a"`. The
1793        // engine already has the parser its operators use.
1794        "jsonpath" => Value::text(crate::json::jsonpath_canonical(t)?),
1795        _ => unreachable!("guarded above"),
1796    };
1797    Ok(Some(out))
1798}
1799
1800/// v7.38 (read01, T20) — width of a `bit` cast target: bare `bit` is `bit(1)`,
1801/// `bit(N)` is N. `None` for `varbit` / `bit varying` (PG rejects int→varbit) and
1802/// any non-bit name.
1803fn bit_cast_width(name: &str) -> Option<(u32, bool)> {
1804    crate::conversions::with_lower_name(name, bit_cast_width_lower)
1805}
1806
1807fn bit_cast_width_lower(lower: &str) -> Option<(u32, bool)> {
1808    let trimmed = lower.trim();
1809    if trimmed == "bit" {
1810        return Some((1, true));
1811    }
1812    // v7.39 (round 281) — `varbit(n)` / `bit varying(n)` adjust on an
1813    // explicit cast too, but only DOWN: PG truncates a too-long value
1814    // and leaves a shorter one alone, where `bit(n)` also pads.
1815    for (prefix, pads) in [("varbit", false), ("bit varying", false), ("bit", true)] {
1816        if let Some(rest) = trimmed.strip_prefix(prefix) {
1817            let rest = rest.trim_start();
1818            if let Some(inner) = rest.strip_prefix('(').and_then(|r| r.strip_suffix(')'))
1819                && let Ok(n) = inner.trim().parse::<u32>()
1820            {
1821                return Some((n, pads));
1822            }
1823        }
1824    }
1825    None
1826}
1827
1828/// v7.38 (read01, T20) — build a `bit(width)` value from an integer: the low
1829/// `width` bits of the two's-complement, packed MSB-first / left-aligned (the
1830/// on-wire bit layout). Widths past 64 sign-extend.
1831fn int_to_bit_string(v: Value<'static>, width: u32) -> Result<Value<'static>, EvalError> {
1832    let n: i64 = match v {
1833        Value::Int(x) => i64::from(x),
1834        Value::BigInt(x) => x,
1835        Value::SmallInt(x) => i64::from(x),
1836        _ => {
1837            return Err(EvalError::TypeMismatch {
1838                detail: "int_to_bit_string: non-integer source".into(),
1839            });
1840        }
1841    };
1842    let w = width as usize;
1843    let mut bytes = alloc::vec![0u8; w.div_ceil(8)];
1844    for i in 0..w {
1845        let p = w - 1 - i; // bit position counted from the LSB
1846        let bit = if p >= 64 {
1847            u8::from(n < 0) // sign-extend beyond the integer's width
1848        } else {
1849            ((n >> p) & 1) as u8
1850        };
1851        if bit != 0 {
1852            bytes[i / 8] |= 1 << (7 - (i % 8));
1853        }
1854    }
1855    Ok(Value::bit_string(width, bytes))
1856}
1857
1858/// Extract the fractional-seconds precision from a temporal cast name like
1859/// `time(3)` / `timestamp(0)` / `timestamptz(2)`; `None` for any non-temporal
1860/// type or a bare temporal type with no `(N)`.
1861fn temporal_typmod(name: &str) -> Option<u8> {
1862    crate::conversions::with_lower_name(name, |lower| {
1863        let (base, rest) = lower.split_once('(')?;
1864        if !matches!(
1865            base.trim(),
1866            "time" | "timetz" | "timestamp" | "timestamptz" | "datetime"
1867        ) {
1868            return None;
1869        }
1870        let digits = rest.trim_start();
1871        let end = digits
1872            .find(|c: char| !c.is_ascii_digit())
1873            .unwrap_or(digits.len());
1874        digits[..end].parse::<u8>().ok()
1875    })
1876}
1877
1878/// Round a TIME / TIMESTAMP value's microsecond field to `prec` fractional-
1879/// second digits (`prec` 0..=6), half-away-from-zero as PG's AdjustTimestamp.
1880/// v7.39 (round 423) — `truncate` selects MySQL's reduction mode. PG's
1881/// AdjustTimestamp ROUNDS half-away-from-zero (`::timestamp(1)` of `.256` is
1882/// `.3`); MariaDB TRUNCATES toward zero (`.2`, measured). Same function, one
1883/// flag, because everything else about the reduction is identical.
1884fn round_temporal_to_precision(v: Value<'static>, prec: u8, truncate: bool) -> Value<'static> {
1885    if prec >= 6 {
1886        return v;
1887    }
1888    let scale = 10i64.pow(u32::from(6 - prec));
1889    let reduce = |micros: i64| -> i64 {
1890        if truncate {
1891            // Toward zero, so a negative time-of-day loses the same digits.
1892            (micros / scale) * scale
1893        } else {
1894            let half = scale / 2;
1895            if micros >= 0 {
1896                ((micros + half) / scale) * scale
1897            } else {
1898                -(((-micros + half) / scale) * scale)
1899            }
1900        }
1901    };
1902    match v {
1903        Value::Timestamp(m) => Value::Timestamp(reduce(m)),
1904        Value::Time(m) => Value::Time(reduce(m)),
1905        other => other,
1906    }
1907}
1908
1909/// v7.39 (round 423) — is `name` a bare temporal type (no `(N)` modifier)?
1910/// MySQL gives those fractional precision ZERO — `CAST(x AS DATETIME)` drops
1911/// the fraction entirely — where PG's `::timestamp` keeps full microseconds.
1912fn is_bare_temporal_type(name: &str) -> bool {
1913    let t = name.trim();
1914    ["time", "timestamp", "datetime"]
1915        .iter()
1916        .any(|k| t.eq_ignore_ascii_case(k))
1917}
1918
1919fn cast_to_int_array(v: Value) -> Result<Value, EvalError> {
1920    match v {
1921        Value::IntArray(items) => Ok(Value::IntArray(items)),
1922        Value::BigIntArray(items) => {
1923            let mut out: Vec<Option<i32>> = Vec::with_capacity(items.len());
1924            for item in items {
1925                match item {
1926                    None => out.push(None),
1927                    Some(n) => match i32::try_from(n) {
1928                        Ok(x) => out.push(Some(x)),
1929                        Err(_) => {
1930                            return Err(EvalError::TypeMismatch {
1931                                detail: alloc::format!("::INT[] element {n} overflows i32"),
1932                            });
1933                        }
1934                    },
1935                }
1936            }
1937            Ok(Value::IntArray(out))
1938        }
1939        Value::Text(s) => {
1940            if let Some(r) = try_cast_2d_array(&s, |row| {
1941                decode_int_array_external(row).map(Value::IntArray)
1942            }) {
1943                return r;
1944            }
1945            decode_int_array_external(&s).map(Value::IntArray)
1946        }
1947        Value::TextArray(items) => {
1948            let mut out: Vec<Option<i32>> = Vec::with_capacity(items.len());
1949            for item in items {
1950                match item {
1951                    None => out.push(None),
1952                    Some(s) => match s.parse::<i32>() {
1953                        Ok(n) => out.push(Some(n)),
1954                        Err(_) => {
1955                            return Err(EvalError::TypeMismatch {
1956                                detail: alloc::format!("::INT[] cannot parse {s:?}"),
1957                            });
1958                        }
1959                    },
1960                }
1961            }
1962            Ok(Value::IntArray(out))
1963        }
1964        other => Err(EvalError::TypeMismatch {
1965            detail: alloc::format!(
1966                "::INT[] does not accept {}",
1967                crate::conversions::pg_type_name_for_error_opt(other.data_type())
1968            ),
1969        }),
1970    }
1971}
1972
1973fn cast_to_bigint_array(v: Value) -> Result<Value, EvalError> {
1974    match v {
1975        Value::BigIntArray(items) => Ok(Value::BigIntArray(items)),
1976        Value::IntArray(items) => Ok(Value::BigIntArray(
1977            items.into_iter().map(|x| x.map(i64::from)).collect(),
1978        )),
1979        Value::Text(s) => {
1980            if let Some(r) = try_cast_2d_array(&s, |row| {
1981                decode_bigint_array_external(row).map(Value::BigIntArray)
1982            }) {
1983                return r;
1984            }
1985            decode_bigint_array_external(&s).map(Value::BigIntArray)
1986        }
1987        Value::TextArray(items) => {
1988            let mut out: Vec<Option<i64>> = Vec::with_capacity(items.len());
1989            for item in items {
1990                match item {
1991                    None => out.push(None),
1992                    Some(s) => match s.parse::<i64>() {
1993                        Ok(n) => out.push(Some(n)),
1994                        Err(_) => {
1995                            return Err(EvalError::TypeMismatch {
1996                                detail: alloc::format!("::BIGINT[] cannot parse {s:?}"),
1997                            });
1998                        }
1999                    },
2000                }
2001            }
2002            Ok(Value::BigIntArray(out))
2003        }
2004        other => Err(EvalError::TypeMismatch {
2005            detail: alloc::format!(
2006                "::BIGINT[] does not accept {}",
2007                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2008            ),
2009        }),
2010    }
2011}
2012
2013/// Cast a possibly-2-D array literal: parse each top-level row with `elem` (the
2014/// 1-D element decoder) and fold into a 2-D value; `None` when the literal is 1-D.
2015fn try_cast_2d_array(
2016    s: &str,
2017    elem: impl Fn(&str) -> Result<Value<'static>, EvalError>,
2018) -> Option<Result<Value<'static>, EvalError>> {
2019    let rows = crate::eval::values::split_2d_rows(s)?;
2020    let mut row_vals: alloc::vec::Vec<Value<'static>> = alloc::vec::Vec::with_capacity(rows.len());
2021    for r in &rows {
2022        match elem(r) {
2023            Ok(v) => row_vals.push(v),
2024            Err(e) => return Some(Err(e)),
2025        }
2026    }
2027    Some(
2028        crate::eval::values::build_2d_from_rows(&row_vals).ok_or_else(|| EvalError::TypeMismatch {
2029            detail: crate::conversions::malformed_array_literal(s),
2030        }),
2031    )
2032}
2033
2034fn decode_int_array_external(s: &str) -> Result<Vec<Option<i32>>, EvalError> {
2035    let trimmed = s.trim();
2036    // v7.39 (read01 jsonfuncs.c) — the json_to_record/populate desugar
2037    // routes JSON array text ("[1,2]") through this cast; accept the
2038    // bracket form alongside PG's brace form.
2039    let inner = trimmed
2040        .strip_prefix('{')
2041        .and_then(|x| x.strip_suffix('}'))
2042        .or_else(|| trimmed.strip_prefix('[').and_then(|x| x.strip_suffix(']')))
2043        .ok_or_else(|| EvalError::TypeMismatch {
2044            detail: crate::conversions::malformed_array_literal(s),
2045        })?;
2046    if inner.trim().is_empty() {
2047        return Ok(Vec::new());
2048    }
2049    inner
2050        .split(',')
2051        .map(|part| {
2052            let p = part.trim();
2053            if p.eq_ignore_ascii_case("NULL") {
2054                Ok(None)
2055            } else {
2056                p.parse::<i32>()
2057                    .map(Some)
2058                    .map_err(|_| EvalError::TypeMismatch {
2059                        detail: alloc::format!("invalid input syntax for type integer: {p:?}"),
2060                    })
2061            }
2062        })
2063        .collect()
2064}
2065
2066fn decode_bigint_array_external(s: &str) -> Result<Vec<Option<i64>>, EvalError> {
2067    let trimmed = s.trim();
2068    let inner = trimmed
2069        .strip_prefix('{')
2070        .and_then(|x| x.strip_suffix('}'))
2071        .or_else(|| trimmed.strip_prefix('[').and_then(|x| x.strip_suffix(']')))
2072        .ok_or_else(|| EvalError::TypeMismatch {
2073            // v7.39 (round 325) — was "BIGmalformed array literal", a
2074            // stray edit that shipped: the message a client saw for
2075            // `'abc'::bigint[]` began with three letters of BIGINT.
2076            detail: crate::conversions::malformed_array_literal(s),
2077        })?;
2078    if inner.trim().is_empty() {
2079        return Ok(Vec::new());
2080    }
2081    inner
2082        .split(',')
2083        .map(|part| {
2084            let p = part.trim();
2085            if p.eq_ignore_ascii_case("NULL") {
2086                Ok(None)
2087            } else {
2088                p.parse::<i64>()
2089                    .map(Some)
2090                    .map_err(|_| EvalError::TypeMismatch {
2091                        detail: alloc::format!("invalid input syntax for type bigint: {p:?}"),
2092                    })
2093            }
2094        })
2095        .collect()
2096}
2097
2098/// v7.10.11 — same decoder as `decode_text_array_literal` in
2099/// `lib.rs`, but lives here so the eval-time cast path stays
2100/// inside `spg-engine::eval`. Kept in lock-step with the engine
2101/// `coerce_value` decoder by tests.
2102fn decode_text_array_external(s: &str) -> Result<Vec<Option<String>>, EvalError> {
2103    let trimmed = s.trim();
2104    let inner = trimmed
2105        .strip_prefix('{')
2106        .and_then(|x| x.strip_suffix('}'))
2107        .or_else(|| trimmed.strip_prefix('[').and_then(|x| x.strip_suffix(']')))
2108        .ok_or_else(|| EvalError::TypeMismatch {
2109            detail: alloc::format!("TEXT[] literal {s:?} must be enclosed in '{{...}}'"),
2110        })?;
2111    let mut out: Vec<Option<String>> = Vec::new();
2112    if inner.trim().is_empty() {
2113        return Ok(out);
2114    }
2115    let bytes = inner.as_bytes();
2116    let mut i = 0;
2117    while i <= bytes.len() {
2118        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
2119            i += 1;
2120        }
2121        if i < bytes.len() && bytes[i] == b'"' {
2122            i += 1;
2123            let mut buf = String::new();
2124            while i < bytes.len() && bytes[i] != b'"' {
2125                if bytes[i] == b'\\' && i + 1 < bytes.len() {
2126                    buf.push(bytes[i + 1] as char);
2127                    i += 2;
2128                } else {
2129                    buf.push(bytes[i] as char);
2130                    i += 1;
2131                }
2132            }
2133            if i >= bytes.len() {
2134                return Err(EvalError::TypeMismatch {
2135                    detail: "unterminated quoted element in TEXT[] literal".into(),
2136                });
2137            }
2138            i += 1;
2139            out.push(Some(buf));
2140        } else {
2141            let start = i;
2142            while i < bytes.len() && bytes[i] != b',' {
2143                i += 1;
2144            }
2145            let raw = inner[start..i].trim();
2146            if raw.eq_ignore_ascii_case("NULL") {
2147                out.push(None);
2148            } else {
2149                out.push(Some(raw.to_string()));
2150            }
2151        }
2152        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
2153            i += 1;
2154        }
2155        if i >= bytes.len() {
2156            break;
2157        }
2158        if bytes[i] != b',' {
2159            return Err(EvalError::TypeMismatch {
2160                detail: "expected ',' between TEXT[] elements".into(),
2161            });
2162        }
2163        i += 1;
2164    }
2165    Ok(out)
2166}
2167
2168fn cast_to_interval(v: Value) -> Result<Value, EvalError> {
2169    match v {
2170        Value::Interval {
2171            months,
2172            days,
2173            micros,
2174            kind,
2175        } => Ok(Value::Interval {
2176            months,
2177            days,
2178            micros,
2179            kind,
2180        }),
2181        Value::Text(s) => {
2182            let (months, days, micros) =
2183                spg_sql::parser::parse_interval_text(&s).ok_or_else(|| {
2184                    EvalError::TypeMismatch {
2185                        // v7.39 (round 324, V42) — PG's wording.
2186                        detail: alloc::format!("invalid input syntax for type interval: \"{s}\""),
2187                    }
2188                })?;
2189            Ok(Value::Interval {
2190                months,
2191                days,
2192                micros,
2193                // v7.38.19 — the parser answers an infinity as the same
2194                // three extreme fields PostgreSQL puts on the wire, so
2195                // nothing here has to know the spelling.
2196                kind: spg_storage::IntervalKind::from_fields(months, days, micros),
2197            })
2198        }
2199        other => Err(EvalError::TypeMismatch {
2200            detail: alloc::format!(
2201                "::INTERVAL only accepts TEXT-shape inputs, got {}",
2202                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2203            ),
2204        }),
2205    }
2206}
2207
2208fn cast_to_date(v: Value) -> Result<Value, EvalError> {
2209    match v {
2210        Value::Date(d) => Ok(Value::Date(d)),
2211        // Integer literals carry days since the Unix epoch — used by
2212        // the `CURRENT_DATE` AST rewrite to inject the wall clock.
2213        Value::Int(n) => Ok(Value::Date(n)),
2214        Value::BigInt(n) => {
2215            i32::try_from(n)
2216                .map(Value::Date)
2217                .map_err(|_| EvalError::TypeMismatch {
2218                    detail: "bigint days-since-epoch out of DATE range".into(),
2219                })
2220        }
2221        // Timestamp truncates to its day boundary.
2222        Value::Timestamp(t) => {
2223            let days = t.div_euclid(86_400_000_000);
2224            i32::try_from(days)
2225                .map(Value::Date)
2226                .map_err(|_| EvalError::TypeMismatch {
2227                    detail: "timestamp out of DATE range".into(),
2228                })
2229        }
2230        Value::Text(s) => {
2231            if let Some(d) = parse_date_literal(&s) {
2232                return Ok(Value::Date(d));
2233            }
2234            // PG accepts a full timestamp string in a DATE cast and
2235            // truncates to the day (verified vs live PG18.4:
2236            // `'2020-01-01 12:00:00'::date` → 2020-01-01; a bad time
2237            // like `'... 25:00:00'` still raises). Reuse the timestamp
2238            // parser — it validates the time-of-day + optional TZ — then
2239            // floor to the date via the same path as the Timestamp arm.
2240            if let Some(t) = parse_timestamp_literal(&s) {
2241                let days = t.div_euclid(86_400_000_000);
2242                return i32::try_from(days)
2243                    .map(Value::Date)
2244                    .map_err(|_| EvalError::TypeMismatch {
2245                        detail: "timestamp out of DATE range".into(),
2246                    });
2247            }
2248            // PG error split: numeric-shaped input whose field values
2249            // fail the calendar checks is "out of range" (plus PG's
2250            // DateStyle hint); anything else is an input-syntax error.
2251            if super::format::date_text_is_field_shaped(&s) {
2252                return Err(EvalError::TypeMismatch {
2253                    detail: format!(
2254                        "date/time field value out of range: {s:?}\n\
2255                         HINT:  Perhaps you need a different \"DateStyle\" setting."
2256                    ),
2257                });
2258            }
2259            Err(EvalError::TypeMismatch {
2260                detail: format!("invalid input syntax for type date: {s:?}"),
2261            })
2262        }
2263        other => Err(EvalError::TypeMismatch {
2264            detail: format!(
2265                "cannot cast {} to DATE",
2266                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2267            ),
2268        }),
2269    }
2270}
2271
2272fn cast_to_timestamp(v: Value) -> Result<Value, EvalError> {
2273    match v {
2274        Value::Timestamp(t) => Ok(Value::Timestamp(t)),
2275        // Int / BigInt carry microseconds since the Unix epoch — used
2276        // by the `NOW()` / `CURRENT_TIMESTAMP` AST rewrite to inject
2277        // the wall clock as a plain integer literal.
2278        Value::Int(n) => Ok(Value::Timestamp(i64::from(n))),
2279        Value::BigInt(n) => Ok(Value::Timestamp(n)),
2280        // DATE → TIMESTAMP picks midnight on the date.
2281        // v7.39 (read01 timestamp.c) — sentinel-aware (the plain multiply
2282        // overflowed on ±infinity dates).
2283        Value::Date(d) => Ok(Value::Timestamp(crate::conversions::date_days_to_micros(d))),
2284        Value::Text(s) => {
2285            // v7.39 (round 289) — the target has no zone, so PG ignores
2286            // any the literal carries: `'…+02'::timestamp` keeps the
2287            // wall clock rather than converting to UTC.
2288            crate::eval::format::parse_timestamp_literal_wall_ordered(
2289                &s,
2290                crate::eval::format::DateOrder::Mdy,
2291            )
2292            .map(Value::Timestamp)
2293            .ok_or_else(|| EvalError::TypeMismatch {
2294                // v7.39 (round 324, V42) — PG's wording, and PG's split
2295                // between "invalid input syntax" and "date/time field
2296                // value out of range".
2297                detail: crate::eval::format::datetime_input_error_text(&s, "timestamp"),
2298            })
2299        }
2300        other => Err(EvalError::TypeMismatch {
2301            detail: format!(
2302                "cannot cast {} to TIMESTAMP",
2303                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2304            ),
2305        }),
2306    }
2307}
2308
2309/// v7.39 (round 310) — `::timestamptz` from text: an offset in the
2310/// literal is APPLIED, unlike the zone-less sibling which discards it.
2311/// Naive input (no offset) is read as UTC, which is what the
2312/// context-aware arm already assumed when it fell through to here.
2313fn cast_to_timestamptz(v: Value) -> Result<Value, EvalError> {
2314    let Value::Text(s) = &v else {
2315        return cast_to_timestamp(v);
2316    };
2317    crate::eval::format::parse_timestamp_literal_tz_ordered(s, crate::eval::format::DateOrder::Mdy)
2318        .map(|(micros, _had_tz)| Value::Timestamp(micros))
2319        .ok_or_else(|| EvalError::TypeMismatch {
2320            // v7.39 (round 324, V42) — and with the RIGHT type name: this arm
2321            // used to report `TIMESTAMP` for a `::timestamptz` cast.
2322            detail: crate::eval::format::datetime_input_error_text(s, "timestamp with time zone"),
2323        })
2324}
2325
2326/// v7.39 (round 254) — PG refuses to cast a NUMERIC special into any
2327/// integer type: `cannot convert NaN to integer` / `cannot convert
2328/// infinity to bigint` (an infinity is named without its sign, probed
2329/// live). Returns `None` for an ordinary value so the caller runs its
2330/// normal conversion.
2331fn cast_numeric_special_reject(
2332    v: &Value,
2333    target: &str,
2334) -> Option<Result<Value<'static>, EvalError>> {
2335    let Value::Numeric { kind, .. } = v else {
2336        return None;
2337    };
2338    if *kind == spg_storage::NumericKind::Finite {
2339        return None;
2340    }
2341    let what = if *kind == spg_storage::NumericKind::NaN {
2342        "NaN"
2343    } else {
2344        "infinity"
2345    };
2346    Some(Err(EvalError::TypeMismatch {
2347        detail: alloc::format!("cannot convert {what} to {target}"),
2348    }))
2349}
2350
2351fn cast_numeric_to_int(v: Value) -> Result<Value, EvalError> {
2352    match v {
2353        // v7.39 (round 633) — SMALLINT. `1::SMALLINT::INT` answered
2354        // "cannot cast smallint to int": the arm was simply absent, next to
2355        // the Int and BigInt ones. Widening a smallint is about as ordinary
2356        // as a cast gets, and PG has it registered as an IMPLICIT cast.
2357        // Same omission shape as the sum accumulator missing SmallInt in
2358        // round 626 — a variant list written out by hand, one entry short.
2359        Value::SmallInt(n) => Ok(Value::Int(i32::from(n))),
2360        Value::Int(n) => Ok(Value::Int(n)),
2361        Value::BigInt(n) => i32::try_from(n)
2362            .map(Value::Int)
2363            // v7.39 (read01 round 79) — PG's wording, which the Float arm two
2364            // arms down was already using: "integer out of range". Drivers match
2365            // on it. Three arms of one function had two different messages.
2366            .map_err(|_| EvalError::TypeMismatch {
2367                detail: "integer out of range".into(),
2368            }),
2369        // PG rounds (half-to-even) coercing a real number to an integer, and
2370        // errors on a non-finite or out-of-range value (`'inf'::int`,
2371        // `1e20::int`) rather than saturating.
2372        #[allow(clippy::cast_possible_truncation)]
2373        Value::Float(x) => {
2374            let r = f64_round_half_even(x);
2375            if !r.is_finite() || !(-2_147_483_648.0..=2_147_483_647.0).contains(&r) {
2376                return Err(EvalError::TypeMismatch {
2377                    detail: "integer out of range".into(),
2378                });
2379            }
2380            Ok(Value::Int(r as i32))
2381        }
2382        // v7.39 (read01 round 112) — `real` (float4) rounds/range-checks the
2383        // same way float8 does; only the float8 arm existed.
2384        #[allow(clippy::cast_possible_truncation)]
2385        Value::Real(x) => {
2386            let r = f64_round_half_even(f64::from(x));
2387            if !r.is_finite() || !(-2_147_483_648.0..=2_147_483_647.0).contains(&r) {
2388                return Err(EvalError::TypeMismatch {
2389                    detail: "integer out of range".into(),
2390                });
2391            }
2392            Ok(Value::Int(r as i32))
2393        }
2394        Value::Numeric { scaled, scale, .. } => {
2395            let rounded = numeric_round_to_i128(scaled, scale);
2396            i32::try_from(rounded)
2397                .map(Value::Int)
2398                .map_err(|_| EvalError::TypeMismatch {
2399                    detail: "integer out of range".into(),
2400                })
2401        }
2402        Value::Text(s) => crate::conversions::parse_pg_int(&s)
2403            .and_then(|n| i32::try_from(n).ok())
2404            .map(Value::Int)
2405            .ok_or_else(|| EvalError::TypeMismatch {
2406                detail: format!("invalid input syntax for type integer: {s:?}"),
2407            }),
2408        Value::Bool(b) => Ok(Value::Int(i32::from(b))),
2409        // v7.39 (read01 char.c) — ("char")::int is the byte value.
2410        Value::Char1(b) => Ok(Value::Int(i32::from(b))),
2411        // PG `bit`/`varbit` → int is the MSB-first bit value.
2412        #[allow(clippy::cast_possible_truncation)]
2413        Value::BitString { nbits, bytes } => Ok(Value::Int(crate::conversions::bit_string_to_i64(
2414            nbits, &bytes,
2415        ) as i32)),
2416        // v7.39 (read01 round 113) — jsonb → int: decode the JSON scalar, then
2417        // round via the numeric arm above. String/array/object/boolean error.
2418        Value::Json(s) => match crate::conversions::jsonb_scalar_for_cast(&s, "integer")? {
2419            crate::conversions::JsonbScalar::Numeric(n) => cast_numeric_to_int(n),
2420            crate::conversions::JsonbScalar::Bool(_) => Err(
2421                crate::conversions::jsonb_cast_type_error("boolean", "integer"),
2422            ),
2423            crate::conversions::JsonbScalar::Null => Ok(Value::Null),
2424        },
2425        other => Err(EvalError::TypeMismatch {
2426            detail: format!(
2427                "cannot cast {} to int",
2428                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2429            ),
2430        }),
2431    }
2432}
2433
2434fn cast_numeric_to_bigint(v: Value) -> Result<Value, EvalError> {
2435    match v {
2436        Value::Int(n) => Ok(Value::BigInt(i64::from(n))),
2437        // v7.39 (round 633) — SMALLINT, missing here for the same reason.
2438        Value::SmallInt(n) => Ok(Value::BigInt(i64::from(n))),
2439        Value::BigInt(n) => Ok(Value::BigInt(n)),
2440        // PG rounds (half-to-even) coercing a real number to bigint, and errors
2441        // on a non-finite or out-of-range value rather than saturating.
2442        #[allow(clippy::cast_possible_truncation)]
2443        Value::Float(x) => {
2444            let r = f64_round_half_even(x);
2445            if !r.is_finite()
2446                || !(-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&r)
2447            {
2448                return Err(EvalError::TypeMismatch {
2449                    detail: "bigint out of range".into(),
2450                });
2451            }
2452            Ok(Value::BigInt(r as i64))
2453        }
2454        // v7.39 (read01 round 112) — `real` (float4) → bigint, matching float8.
2455        #[allow(clippy::cast_possible_truncation)]
2456        Value::Real(x) => {
2457            let r = f64_round_half_even(f64::from(x));
2458            if !r.is_finite()
2459                || !(-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&r)
2460            {
2461                return Err(EvalError::TypeMismatch {
2462                    detail: "bigint out of range".into(),
2463                });
2464            }
2465            Ok(Value::BigInt(r as i64))
2466        }
2467        Value::Numeric { scaled, scale, .. } => {
2468            let rounded = numeric_round_to_i128(scaled, scale);
2469            i64::try_from(rounded)
2470                .map(Value::BigInt)
2471                .map_err(|_| EvalError::TypeMismatch {
2472                    detail: format!("numeric {rounded} does not fit in bigint"),
2473                })
2474        }
2475        Value::Text(s) => crate::conversions::parse_pg_int(&s)
2476            .map(Value::BigInt)
2477            .ok_or_else(|| EvalError::TypeMismatch {
2478                // v7.39 (round 324, V42) — PG's wording.
2479                detail: format!("invalid input syntax for type bigint: \"{s}\""),
2480            }),
2481        Value::Bool(b) => Ok(Value::BigInt(i64::from(b))),
2482        // PG `bit`/`varbit` → bigint is the MSB-first bit value.
2483        Value::BitString { nbits, bytes } => Ok(Value::BigInt(
2484            crate::conversions::bit_string_to_i64(nbits, &bytes),
2485        )),
2486        // v7.39 (read01 round 113) — jsonb → bigint.
2487        Value::Json(s) => match crate::conversions::jsonb_scalar_for_cast(&s, "bigint")? {
2488            crate::conversions::JsonbScalar::Numeric(n) => cast_numeric_to_bigint(n),
2489            crate::conversions::JsonbScalar::Bool(_) => Err(
2490                crate::conversions::jsonb_cast_type_error("boolean", "bigint"),
2491            ),
2492            crate::conversions::JsonbScalar::Null => Ok(Value::Null),
2493        },
2494        other => Err(EvalError::TypeMismatch {
2495            detail: format!(
2496                "cannot cast {} to bigint",
2497                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2498            ),
2499        }),
2500    }
2501}
2502
2503fn cast_numeric_to_float(v: Value) -> Result<Value, EvalError> {
2504    match v {
2505        Value::Int(n) => Ok(Value::Float(f64::from(n))),
2506        #[allow(clippy::cast_precision_loss)]
2507        Value::BigInt(n) => Ok(Value::Float(n as f64)),
2508        Value::Float(x) => Ok(Value::Float(x)),
2509        // PG's numeric→double precision is an implicit cast; a
2510        // `Value::Numeric` (from `::numeric`, a numeric column, or
2511        // numeric arithmetic) must convert to f64, not error.
2512        #[allow(clippy::cast_precision_loss)]
2513        // v7.39 (round 254) — a special crosses to its IEEE twin.
2514        Value::Numeric { kind, .. } if kind != spg_storage::NumericKind::Finite => {
2515            Ok(Value::Float(match kind {
2516                spg_storage::NumericKind::NaN => f64::NAN,
2517                spg_storage::NumericKind::PosInf => f64::INFINITY,
2518                _ => f64::NEG_INFINITY,
2519            }))
2520        }
2521        Value::Numeric { scaled, scale, .. } => Ok(Value::Float(
2522            (scaled as f64) / f64_powi(10.0, i32::from(scale)),
2523        )),
2524        Value::Text(s) => {
2525            let t = s.trim();
2526            // Unparseable → invalid syntax; parseable-but-out-of-range (overflow
2527            // to ±∞ / nonzero underflow to 0) → out of range, the way PG's
2528            // float8in does, rather than silently yielding Infinity/0. Shared
2529            // with the Named-cast coerce path so `::float` and `::float8` agree.
2530            if t.parse::<f64>().is_err() {
2531                return Err(EvalError::TypeMismatch {
2532                    detail: format!("cannot parse {s:?} as float"),
2533                });
2534            }
2535            crate::conversions::parse_float8(t)
2536                .map(Value::Float)
2537                .ok_or_else(|| EvalError::TypeMismatch {
2538                    detail: format!("\"{t}\" is out of range for type double precision"),
2539                })
2540        }
2541        // v7.39 (read01 round 113) — jsonb → double precision.
2542        Value::Json(s) => {
2543            match crate::conversions::jsonb_scalar_for_cast(&s, "double precision")? {
2544                crate::conversions::JsonbScalar::Numeric(n) => cast_numeric_to_float(n),
2545                crate::conversions::JsonbScalar::Bool(_) => Err(
2546                    crate::conversions::jsonb_cast_type_error("boolean", "double precision"),
2547                ),
2548                crate::conversions::JsonbScalar::Null => Ok(Value::Null),
2549            }
2550        }
2551        other => Err(EvalError::TypeMismatch {
2552            detail: format!(
2553                "cannot cast {} to float",
2554                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2555            ),
2556        }),
2557    }
2558}
2559
2560fn cast_to_bool(v: Value) -> Result<Value, EvalError> {
2561    match v {
2562        Value::Bool(b) => Ok(Value::Bool(b)),
2563        Value::Int(n) => Ok(Value::Bool(n != 0)),
2564        Value::BigInt(n) => Ok(Value::Bool(n != 0)),
2565        Value::Text(s) => {
2566            // PG boolin accepts any unambiguous prefix of true/false/yes/no
2567            // plus on/off/1/0 (case-insensitive, trimmed); `o` alone is
2568            // ambiguous (on vs off) and errors.
2569            let lo = s.trim().to_ascii_lowercase();
2570            match lo.as_str() {
2571                "1" | "t" | "tr" | "tru" | "true" | "y" | "ye" | "yes" | "on" => {
2572                    Ok(Value::Bool(true))
2573                }
2574                "0" | "f" | "fa" | "fal" | "fals" | "false" | "n" | "no" | "of" | "off" => {
2575                    Ok(Value::Bool(false))
2576                }
2577                _ => Err(EvalError::TypeMismatch {
2578                    detail: format!("invalid input syntax for type boolean: {:?}", s.trim()),
2579                }),
2580            }
2581        }
2582        // v7.39 (read01 round 113) — jsonb → boolean accepts only JSON
2583        // true/false; a JSON number/string/array/object errors.
2584        Value::Json(s) => match crate::conversions::jsonb_scalar_for_cast(&s, "boolean")? {
2585            crate::conversions::JsonbScalar::Bool(b) => Ok(Value::Bool(b)),
2586            crate::conversions::JsonbScalar::Numeric(_) => Err(
2587                crate::conversions::jsonb_cast_type_error("numeric", "boolean"),
2588            ),
2589            crate::conversions::JsonbScalar::Null => Ok(Value::Null),
2590        },
2591        other => Err(EvalError::TypeMismatch {
2592            detail: format!(
2593                "cannot cast {} to bool",
2594                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2595            ),
2596        }),
2597    }
2598}
2599
2600/// Parse a `Value::text("[1.0, 2.0, 3.0]")` into a `Value::vector(..)`. Mirrors
2601/// pgvector's `'[..]'::vector` cast. NULL casts as NULL.
2602pub fn cast_to_vector(v: Value) -> Result<Value<'static>, EvalError> {
2603    match v {
2604        Value::Null => Ok(Value::Null),
2605        Value::Vector(v) => Ok(Value::vector(v.into_owned())),
2606        Value::Text(s) => {
2607            parse_vector_text(&s)
2608                .map(Value::vector)
2609                .ok_or_else(|| EvalError::TypeMismatch {
2610                    detail: format!("cannot parse {s:?} as a vector literal"),
2611                })
2612        }
2613        other => Err(EvalError::TypeMismatch {
2614            detail: format!(
2615                "::vector requires text input, got {}",
2616                crate::conversions::pg_type_name_for_error_opt(other.data_type())
2617            ),
2618        }),
2619    }
2620}
2621
2622/// Parse `"[1.0, 2.0, -3]"` into `Vec<f32>`. Returns `None` on malformed input.
2623pub fn parse_vector_text(s: &str) -> Option<Vec<f32>> {
2624    let trimmed = s.trim();
2625    let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
2626    let trimmed_inner = inner.trim();
2627    if trimmed_inner.is_empty() {
2628        return Some(Vec::new());
2629    }
2630    let mut out = Vec::new();
2631    for part in trimmed_inner.split(',') {
2632        let f: f32 = part.trim().parse().ok()?;
2633        out.push(f);
2634    }
2635    Some(out)
2636}
2637
2638#[cfg(test)]
2639mod round613_plain_named_targets {
2640    use super::*;
2641
2642    /// v7.39 (round 613) — the shortcut is only equivalent to walking the
2643    /// arm while these hold. Checked here rather than by eye, so a name that
2644    /// grows a special case above the resolve fails the gate instead of
2645    /// silently taking the wrong path.
2646    fn assert_no_arm_above_the_resolve_claims(name: &str) {
2647        assert!(
2648            !REG_MISC_TYPES.iter().any(|k| name.eq_ignore_ascii_case(k)),
2649            "{name} is a reg-misc type"
2650        );
2651        assert!(
2652            !CATALOG_SCALAR_TYPES
2653                .iter()
2654                .any(|k| name.eq_ignore_ascii_case(k)),
2655            "{name} is a catalog scalar"
2656        );
2657        assert!(
2658            !OPAQUE_TYPES.iter().any(|k| name.eq_ignore_ascii_case(k)),
2659            "{name} is a pseudotype"
2660        );
2661        for special in [
2662            "__bit_literal",
2663            "tid",
2664            "xid",
2665            "xid8",
2666            "jsonpath",
2667            "binary",
2668            "signed",
2669            "unsigned",
2670        ] {
2671            assert!(
2672                !name.eq_ignore_ascii_case(special),
2673                "{name} has its own arm ({special})"
2674            );
2675        }
2676        assert!(bit_cast_width(name).is_none(), "{name} is a bit spelling");
2677        assert!(
2678            temporal_typmod(name).is_none(),
2679            "{name} carries a temporal precision"
2680        );
2681        assert!(
2682            !is_bare_temporal_type(name),
2683            "{name} is a bare temporal type"
2684        );
2685        assert!(
2686            matches!(cast_catalog_scalar(name, &Value::text("x")), Ok(None)),
2687            "{name} is claimed by the catalog-scalar arm"
2688        );
2689    }
2690
2691    #[test]
2692    fn every_plain_target_resolves_to_the_type_the_table_claims() {
2693        for (name, dt) in PLAIN_NAMED_TARGETS {
2694            assert_eq!(
2695                crate::conversions::type_name_to_data_type(name),
2696                Some(*dt),
2697                "{name} does not resolve to the type the table gives it"
2698            );
2699            assert_eq!(plain_named_target(name), Some(*dt));
2700            // The spelling is matched without regard to case.
2701            assert_eq!(plain_named_target(&name.to_uppercase()), Some(*dt));
2702            assert_no_arm_above_the_resolve_claims(name);
2703        }
2704    }
2705
2706    #[test]
2707    fn every_typmod_head_is_plain_and_resolves_through_the_type_table() {
2708        for head in PLAIN_NAMED_HEADS {
2709            assert_no_arm_above_the_resolve_claims(head);
2710            for spelled in [alloc::format!("{head}(4)"), alloc::format!("{head}(10,2)")] {
2711                assert_no_arm_above_the_resolve_claims(&spelled);
2712                assert_eq!(
2713                    plain_named_target(&spelled),
2714                    crate::conversions::type_name_to_data_type(&spelled),
2715                    "{spelled} takes a different type through the shortcut"
2716                );
2717            }
2718            // A bare head with no typmod only shortcuts when it is in the
2719            // exact table; the head list alone must not claim it.
2720            let bare = plain_named_target(head);
2721            let exact = PLAIN_NAMED_TARGETS
2722                .iter()
2723                .find(|(k, _)| head.eq_ignore_ascii_case(k))
2724                .map(|(_, dt)| *dt);
2725            assert_eq!(bare, exact, "{head} bare");
2726        }
2727    }
2728
2729    #[test]
2730    fn a_name_with_its_own_arm_is_not_shortcut() {
2731        for name in [
2732            "regproc",
2733            "aclitem",
2734            "anyarray",
2735            "tid",
2736            "xid",
2737            "jsonpath",
2738            "bit",
2739            "bit(4)",
2740            "timestamp",
2741            "timestamp(2)",
2742            "time(3)",
2743            "nosuchtype",
2744            "int4range",
2745        ] {
2746            assert_eq!(plain_named_target(name), None, "{name} was shortcut");
2747        }
2748    }
2749}