Skip to main content

spg_engine/
conversions.rs

1//! Type conversions — Value/literal <-> text/bytes/special-format. The
2//! coercion entry point (`coerce_value`) plus every parser/formatter it
3//! leans on: bytea, text/2-D arrays, hstore, ranges, money, time, year,
4//! and literal->Value. Split out of `lib.rs` (v7.32 engine
5//! modularisation); a self-contained cluster (its members call each
6//! other), depending only on spg_storage/spg_sql, `eval`, and `numeric`.
7
8use alloc::string::ToString;
9use alloc::vec::Vec;
10
11use spg_sql::ast::{ColumnTypeName, Expr, Literal, UnOp, VecEncoding as SqlVecEncoding};
12use spg_storage::{ColumnSchema, DataType, StorageError, Value, VecEncoding};
13
14use crate::EngineError;
15use crate::eval::{self, EvalContext, EvalError};
16use crate::numeric::{
17    numeric_from_float, numeric_from_integer, numeric_rescale, numeric_round_to_integer,
18    parse_numeric_text,
19};
20
21/// v7.10.4 — decode a BYTEA literal. Accepts:
22///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
23///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
24///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
25/// v7.39 (round 325, V57) — errors are PG's own, verbatim:
26/// `invalid hexadecimal digit: "Z"` (naming the offending character) and
27/// `invalid hexadecimal data: odd number of digits`. They used to be SPG
28/// phrasings wrapped in `cannot parse "…" as BYTEA: `.
29/// PostgreSQL's pseudo-types, canonically spelled.
30///
31/// v7.38.19 — `SELECT typname FROM pg_type WHERE typtype = 'p'` on
32/// PostgreSQL 18.4, minus `any` and `_record`. `any` is a reserved word,
33/// so PG answers a syntax error rather than a type error and matching
34/// its list here would replace one with the other; `_record` is the
35/// array spelling and is not written by hand.
36///
37/// A pseudo-type has no storage. PG refuses a column declared with one,
38/// and it refuses it as an INVALID TABLE DEFINITION (42P16) rather than
39/// an undefined type (42704) -- the name exists, it just cannot hold a
40/// value. SPG answered `type "cstring" does not exist`, which is the
41/// wrong class and the wrong claim.
42pub(crate) fn pseudo_type(name: &str) -> Option<&'static str> {
43    const NAMES: &[&str] = &[
44        "anyarray",
45        "anycompatible",
46        "anycompatiblearray",
47        "anycompatiblemultirange",
48        "anycompatiblenonarray",
49        "anycompatiblerange",
50        "anyelement",
51        "anyenum",
52        "anymultirange",
53        "anynonarray",
54        "anyrange",
55        "cstring",
56        "event_trigger",
57        "fdw_handler",
58        "index_am_handler",
59        "internal",
60        "language_handler",
61        "pg_ddl_command",
62        "record",
63        "table_am_handler",
64        "trigger",
65        "tsm_handler",
66        "unknown",
67        "void",
68    ];
69    NAMES.iter().find(|n| n.eq_ignore_ascii_case(name)).copied()
70}
71
72pub(crate) fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, alloc::string::String> {
73    let s = s.trim();
74    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
75        // Hex form. Each pair of hex digits → one byte.
76        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
77        if cleaned.len() % 2 != 0 {
78            return Err(alloc::string::String::from(
79                "invalid hexadecimal data: odd number of digits",
80            ));
81        }
82        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
83        let cleaned_bytes = cleaned.as_bytes();
84        for i in (0..cleaned_bytes.len()).step_by(2) {
85            let hi = hex_nibble(cleaned_bytes[i]).map_err(|()| bad_hex_digit(cleaned_bytes[i]))?;
86            let lo = hex_nibble(cleaned_bytes[i + 1])
87                .map_err(|()| bad_hex_digit(cleaned_bytes[i + 1]))?;
88            out.push((hi << 4) | lo);
89        }
90        return Ok(out);
91    }
92    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
93    // sequences decode; anything else is a literal byte.
94    let bytes = s.as_bytes();
95    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
96    let mut i = 0;
97    while i < bytes.len() {
98        let b = bytes[i];
99        if b == b'\\' && i + 1 < bytes.len() {
100            let n = bytes[i + 1];
101            if n == b'\\' {
102                out.push(b'\\');
103                i += 2;
104                continue;
105            }
106            if n.is_ascii_digit()
107                && i + 3 < bytes.len()
108                && bytes[i + 2].is_ascii_digit()
109                && bytes[i + 3].is_ascii_digit()
110            {
111                let oct = |x: u8| (x - b'0') as u32;
112                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
113                if v <= 0xFF {
114                    out.push(v as u8);
115                    i += 4;
116                    continue;
117                }
118            }
119        }
120        out.push(b);
121        i += 1;
122    }
123    Ok(out)
124}
125
126pub(crate) fn hex_nibble(b: u8) -> Result<u8, ()> {
127    match b {
128        b'0'..=b'9' => Ok(b - b'0'),
129        b'a'..=b'f' => Ok(b - b'a' + 10),
130        b'A'..=b'F' => Ok(b - b'A' + 10),
131        _ => Err(()),
132    }
133}
134
135/// PG names the character it choked on.
136fn bad_hex_digit(b: u8) -> alloc::string::String {
137    alloc::format!("invalid hexadecimal digit: \"{}\"", b as char)
138}
139
140/// v7.37.5 γ — uniform array-of-scalar shape detector. Returns
141/// `Some(kind)` only when every non-NULL element fits the same
142/// new-array element type; `None` falls back to the legacy
143/// `array_literal_widen` Int/BigInt/Text path.
144#[derive(Clone, Copy)]
145enum UniformArrayKind {
146    Bool,
147    Float,
148    Numeric,
149    Date,
150    Timestamp,
151    Uuid,
152    Bytes,
153    Interval,
154    Money,
155}
156
157impl UniformArrayKind {
158    fn build(self, items: alloc::vec::Vec<Value<'static>>) -> Value<'static> {
159        match self {
160            Self::Bool => Value::BoolArray(
161                items
162                    .into_iter()
163                    .map(|v| match v {
164                        Value::Null => None,
165                        Value::Bool(b) => Some(b),
166                        _ => unreachable!("uniform Bool"),
167                    })
168                    .collect(),
169            ),
170            Self::Float => Value::FloatArray(
171                items
172                    .into_iter()
173                    .map(|v| match v {
174                        Value::Null => None,
175                        Value::Float(x) => Some(x),
176                        _ => unreachable!("uniform Float"),
177                    })
178                    .collect(),
179            ),
180            Self::Numeric => Value::NumericArray(
181                items
182                    .into_iter()
183                    .map(|v| match v {
184                        Value::Null => None,
185                        Value::Numeric { scaled, scale, .. } => Some((scaled, scale)),
186                        _ => unreachable!("uniform Numeric"),
187                    })
188                    .collect(),
189            ),
190            Self::Date => Value::DateArray(
191                items
192                    .into_iter()
193                    .map(|v| match v {
194                        Value::Null => None,
195                        Value::Date(d) => Some(d),
196                        _ => unreachable!("uniform Date"),
197                    })
198                    .collect(),
199            ),
200            Self::Timestamp => Value::TimestampArray(
201                items
202                    .into_iter()
203                    .map(|v| match v {
204                        Value::Null => None,
205                        Value::Timestamp(t) => Some(t),
206                        _ => unreachable!("uniform Timestamp"),
207                    })
208                    .collect(),
209            ),
210            Self::Uuid => Value::UuidArray(
211                items
212                    .into_iter()
213                    .map(|v| match v {
214                        Value::Null => None,
215                        Value::Uuid(b) => Some(b),
216                        _ => unreachable!("uniform Uuid"),
217                    })
218                    .collect(),
219            ),
220            Self::Bytes => Value::BytesArray(
221                items
222                    .into_iter()
223                    .map(|v| match v {
224                        Value::Null => None,
225                        Value::Bytes(b) => Some(b.into_owned()),
226                        _ => unreachable!("uniform Bytes"),
227                    })
228                    .collect(),
229            ),
230            Self::Interval => Value::IntervalArray(
231                items
232                    .into_iter()
233                    .map(|v| match v {
234                        Value::Null => None,
235                        Value::Interval {
236                            months,
237                            days,
238                            micros,
239                            kind,
240                        } => Some(spg_storage::IntervalSpan {
241                            months,
242                            days,
243                            micros,
244                            kind,
245                        }),
246                        _ => unreachable!("uniform Interval"),
247                    })
248                    .collect(),
249            ),
250            Self::Money => Value::MoneyArray(
251                items
252                    .into_iter()
253                    .map(|v| match v {
254                        Value::Null => None,
255                        Value::Money(c) => Some(c),
256                        _ => unreachable!("uniform Money"),
257                    })
258                    .collect(),
259            ),
260        }
261    }
262}
263
264fn widen_uniform_typed(items: &[Value<'static>]) -> Option<UniformArrayKind> {
265    let mut kind: Option<UniformArrayKind> = None;
266    let mut saw_non_null = false;
267    for v in items {
268        let this = match v {
269            Value::Null => continue,
270            Value::Bool(_) => UniformArrayKind::Bool,
271            Value::Float(_) => UniformArrayKind::Float,
272            Value::Numeric { .. } => UniformArrayKind::Numeric,
273            Value::Date(_) => UniformArrayKind::Date,
274            Value::Timestamp(_) => UniformArrayKind::Timestamp,
275            Value::Uuid(_) => UniformArrayKind::Uuid,
276            Value::Bytes(_) => UniformArrayKind::Bytes,
277            Value::Interval { .. } => UniformArrayKind::Interval,
278            Value::Money(_) => UniformArrayKind::Money,
279            // Int / BigInt / Text / Json — defer to the legacy
280            // Int/Text widen below so the existing IntArray /
281            // BigIntArray / TextArray behaviour is unchanged.
282            _ => return None,
283        };
284        match kind {
285            None => kind = Some(this),
286            Some(prev) if discriminant_eq(prev, this) => {}
287            Some(_) => return None,
288        }
289        saw_non_null = true;
290    }
291    if saw_non_null { kind } else { None }
292}
293
294fn discriminant_eq(a: UniformArrayKind, b: UniformArrayKind) -> bool {
295    matches!(
296        (a, b),
297        (UniformArrayKind::Bool, UniformArrayKind::Bool)
298            | (UniformArrayKind::Float, UniformArrayKind::Float)
299            | (UniformArrayKind::Numeric, UniformArrayKind::Numeric)
300            | (UniformArrayKind::Date, UniformArrayKind::Date)
301            | (UniformArrayKind::Timestamp, UniformArrayKind::Timestamp)
302            | (UniformArrayKind::Uuid, UniformArrayKind::Uuid)
303            | (UniformArrayKind::Bytes, UniformArrayKind::Bytes)
304            | (UniformArrayKind::Interval, UniformArrayKind::Interval)
305            | (UniformArrayKind::Money, UniformArrayKind::Money)
306    )
307}
308
309/// v7.10.11 — decode a PG TEXT[] external array form
310/// (`{a,b,NULL}` with optional double-quoted elements). The
311/// engine takes a leading/trailing `{`/`}` and splits at commas.
312/// Quoted elements (`"hello, world"`) preserve embedded commas;
313/// `\\` and `\"` decode to literal backslash / quote. Plain
314/// unquoted `NULL` (case-insensitive) maps to `None`.
315/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
316/// element values. Single-element-type rules:
317///   - all NULL / all Text → TextArray
318///   - all Int (or Int+NULL) → IntArray
319///   - any BigInt without Text → BigIntArray (widening)
320///   - any Text → TextArray (fallback; non-string elements
321///     render as text)
322pub(crate) fn array_literal_widen(items: alloc::vec::Vec<Value<'static>>) -> Value<'static> {
323    // v7.37.5 γ — first, detect a uniform new-array-type. If every
324    // non-NULL element shares one of the array-of-scalar element
325    // shapes (Bool / Float / Numeric / Date / Timestamp / Uuid /
326    // Bytes / Interval), build the matching typed array directly
327    // so INSERT to a typed column doesn't have to go through the
328    // TextArray fallback + coerce chain.
329    // v7.39 (read01 round 75) — rows that are themselves arrays make a 2-D array.
330    // This path (the INSERT literal one) did not know 2-D at all, so an
331    // `ARRAY[ARRAY[…]]` in a VALUES list silently collapsed to text[] — the same
332    // per-variant hole, in the builder next door.
333    if let Some(m) = crate::eval::values::build_2d_from_rows(&items) {
334        return m;
335    }
336    if let Some(arr) = widen_uniform_typed(&items) {
337        return arr.build(items);
338    }
339    let mut has_text = false;
340    let mut has_bigint = false;
341    let mut has_int = false;
342    for v in &items {
343        match v {
344            Value::Null => {}
345            Value::Text(_) | Value::Json(_) => has_text = true,
346            Value::BigInt(_) => has_bigint = true,
347            Value::Int(_) | Value::SmallInt(_) => has_int = true,
348            _ => has_text = true,
349        }
350    }
351    if has_text || (!has_bigint && !has_int) {
352        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
353            .into_iter()
354            .map(|v| match v {
355                Value::Null => None,
356                Value::Text(s) | Value::Json(s) => Some(s.into_owned()),
357                other => Some(alloc::format!("{other:?}")),
358            })
359            .collect();
360        return Value::TextArray(out);
361    }
362    if has_bigint {
363        let out: alloc::vec::Vec<Option<i64>> = items
364            .into_iter()
365            .map(|v| match v {
366                Value::Null => None,
367                Value::Int(n) => Some(i64::from(n)),
368                Value::SmallInt(n) => Some(i64::from(n)),
369                Value::BigInt(n) => Some(n),
370                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
371            })
372            .collect();
373        return Value::BigIntArray(out);
374    }
375    let out: alloc::vec::Vec<Option<i32>> = items
376        .into_iter()
377        .map(|v| match v {
378            Value::Null => None,
379            Value::Int(n) => Some(n),
380            Value::SmallInt(n) => Some(i32::from(n)),
381            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
382        })
383        .collect();
384    Value::IntArray(out)
385}
386
387/// v7.39 (round 325, V57) — PG's message for a literal that will not
388/// become an array, DETAIL and all. Measured on PG 18.4 (INSERT into a
389/// typed column):
390///
391/// | literal | DETAIL |
392/// |---|---|
393/// | `abc` | `Array value must start with "{" or dimension information.` |
394/// | `{1,2` | `Unexpected end of input.` |
395/// | `{1,2}}` · `{1,2}x` | `Junk after closing right brace.` |
396/// | `{1,}` | `Unexpected "}" character.` |
397///
398/// The `" DETAIL: "` separator is the one the wire splits into the
399/// ErrorResponse `D` field. An element that fails to convert is NOT this
400/// error: PG reports the ELEMENT type's own input-syntax error, which is
401/// what the per-element coercion below already produces.
402#[must_use]
403pub(crate) fn malformed_array_literal(text: &str) -> alloc::string::String {
404    let t = text.trim();
405    let detail = if !t.starts_with('{') {
406        "Array value must start with \"{\" or dimension information."
407    } else {
408        // The array ends at the FIRST unquoted `}` — the same rule the
409        // decoder applies, so `{1,2}}` is junk after the brace rather
410        // than an unterminated literal.
411        match first_unquoted_close_brace(&t[1..]) {
412            None => "Unexpected end of input.",
413            Some(close) => {
414                let inner = &t[1..1 + close];
415                if !t[1 + close + 1..].trim().is_empty() {
416                    "Junk after closing right brace."
417                } else if inner.trim_end().ends_with(',') {
418                    "Unexpected \"}\" character."
419                } else {
420                    "Unexpected end of input."
421                }
422            }
423        }
424    };
425    alloc::format!("malformed array literal: \"{text}\" DETAIL: {detail}")
426}
427
428/// Byte offset of the first `}` outside quotes, if any.
429fn first_unquoted_close_brace(body: &str) -> Option<usize> {
430    let bs = body.as_bytes();
431    let mut in_quote = false;
432    let mut k = 0;
433    while k < bs.len() {
434        match bs[k] {
435            b'\\' if in_quote => k += 1,
436            b'"' => in_quote = !in_quote,
437            b'}' if !in_quote => return Some(k),
438            _ => {}
439        }
440        k += 1;
441    }
442    None
443}
444
445pub(crate) fn decode_text_array_literal(
446    s: &str,
447) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
448    let trimmed = s.trim();
449    // v7.39 (round 325, V57) — the array ends at the FIRST unquoted `}`,
450    // and anything after it is junk. Peeling one brace off each end let
451    // `{1,2}}` through as the elements `1` and `2}`, so the failure was
452    // reported as a bad INTEGER rather than PG's "Junk after closing right
453    // brace." — a wrong diagnosis, not just wrong words.
454    let body = trimmed
455        .strip_prefix('{')
456        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
457    let close =
458        first_unquoted_close_brace(body).ok_or("TEXT[] literal must be enclosed in '{...}'")?;
459    if !body[close + 1..].trim().is_empty() {
460        return Err("junk after closing right brace");
461    }
462    let inner = &body[..close];
463    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
464    if inner.trim().is_empty() {
465        return Ok(out);
466    }
467    let bytes = inner.as_bytes();
468    let mut i = 0;
469    while i <= bytes.len() {
470        // Skip leading whitespace.
471        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
472            i += 1;
473        }
474        // Quoted element.
475        if i < bytes.len() && bytes[i] == b'"' {
476            i += 1; // open quote
477            let mut buf = alloc::string::String::new();
478            while i < bytes.len() && bytes[i] != b'"' {
479                if bytes[i] == b'\\' && i + 1 < bytes.len() {
480                    buf.push(bytes[i + 1] as char);
481                    i += 2;
482                } else {
483                    buf.push(bytes[i] as char);
484                    i += 1;
485                }
486            }
487            if i >= bytes.len() {
488                return Err("unterminated quoted element");
489            }
490            i += 1; // close quote
491            out.push(Some(buf));
492        } else {
493            // Unquoted element — read until next comma or end.
494            let start = i;
495            while i < bytes.len() && bytes[i] != b',' {
496                i += 1;
497            }
498            let raw = inner[start..i].trim();
499            // v7.39 (round 325, V57) — PG rejects an empty UNQUOTED
500            // element (`{1,}` is `Unexpected "}" character.`); it used to
501            // become an empty string, which then failed as a bad element
502            // of whatever the array's type was.
503            if raw.is_empty() {
504                return Err("empty array element");
505            }
506            if raw.eq_ignore_ascii_case("NULL") {
507                out.push(None);
508            } else {
509                out.push(Some(alloc::string::ToString::to_string(raw)));
510            }
511        }
512        // Skip whitespace, expect comma or end.
513        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
514            i += 1;
515        }
516        if i >= bytes.len() {
517            break;
518        }
519        if bytes[i] != b',' {
520            return Err("expected ',' between TEXT[] elements");
521        }
522        i += 1;
523    }
524    Ok(out)
525}
526
527/// v7.10.11 — encode a TEXT[] back into the PG external array
528/// form. NULL elements become the literal `NULL`; elements
529/// containing commas, quotes, backslashes, or braces are
530/// double-quoted with `\\` / `\"` escapes.
531pub(crate) fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
532    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
533    out.push('{');
534    for (i, item) in items.iter().enumerate() {
535        if i > 0 {
536            out.push(',');
537        }
538        match item {
539            None => out.push_str("NULL"),
540            Some(s) => {
541                let needs_quote = s.is_empty()
542                    || s.eq_ignore_ascii_case("NULL")
543                    || s.chars()
544                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
545                if needs_quote {
546                    out.push('"');
547                    for c in s.chars() {
548                        if c == '"' || c == '\\' {
549                            out.push('\\');
550                        }
551                        out.push(c);
552                    }
553                    out.push('"');
554                } else {
555                    out.push_str(s);
556                }
557            }
558        }
559    }
560    out.push('}');
561    out
562}
563
564/// v7.10.4 — encode BYTEA bytes in PG hex output format
565/// (`\x` prefix, lowercase hex pairs). Used by Text-side
566/// round-trip + the wire layer's text-mode encoder.
567pub(crate) fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
568    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
569    out.push_str("\\x");
570    for byte in b {
571        let hi = byte >> 4;
572        let lo = byte & 0x0F;
573        out.push(hex_digit(hi));
574        out.push(hex_digit(lo));
575    }
576    out
577}
578
579pub(crate) const fn hex_digit(n: u8) -> char {
580    match n {
581        0..=9 => (b'0' + n) as char,
582        10..=15 => (b'a' + n - 10) as char,
583        _ => '?',
584    }
585}
586
587/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
588/// a flat key→value map. Empty string → empty map. Duplicate
589/// keys keep the FIRST occurrence (PG18-measured, round 780; the old
590/// note claimed last-write-wins).
591///
592/// Accepted shapes (minimal subset):
593///   * `'a=>1, b=>2'`            — bareword keys/values
594///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
595///   * `'a=>NULL'`               — case-insensitive NULL token
596///     surfaces as `None` (no quotes around NULL)
597///
598/// Returns None on parse failure → caller surfaces as hard error.
599pub(crate) fn parse_hstore_str(
600    s: &str,
601) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
602    let bytes = s.as_bytes();
603    let mut i = 0;
604    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
605    let skip_ws = |bytes: &[u8], i: &mut usize| {
606        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
607            *i += 1;
608        }
609    };
610    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
611        if *i >= bytes.len() {
612            return None;
613        }
614        if bytes[*i] == b'"' {
615            *i += 1;
616            let mut out = alloc::string::String::new();
617            while *i < bytes.len() {
618                match bytes[*i] {
619                    b'"' => {
620                        *i += 1;
621                        return Some(out);
622                    }
623                    b'\\' if *i + 1 < bytes.len() => {
624                        out.push(bytes[*i + 1] as char);
625                        *i += 2;
626                    }
627                    c => {
628                        out.push(c as char);
629                        *i += 1;
630                    }
631                }
632            }
633            None
634        } else {
635            let start = *i;
636            while *i < bytes.len()
637                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
638            {
639                *i += 1;
640            }
641            if *i == start {
642                return None;
643            }
644            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
645        }
646    };
647    skip_ws(bytes, &mut i);
648    while i < bytes.len() {
649        let key = parse_token(bytes, &mut i)?;
650        skip_ws(bytes, &mut i);
651        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
652            return None;
653        }
654        i += 2;
655        skip_ws(bytes, &mut i);
656        // Check for unquoted NULL token (case-insensitive).
657        let val_token = if i + 4 <= bytes.len()
658            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
659            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
660        {
661            i += 4;
662            None
663        } else {
664            Some(parse_token(bytes, &mut i)?)
665        };
666        // v7.39 (round 780, F31-D1) — PG's hstore_in keeps the FIRST
667        // occurrence of a duplicate key (measured: 'a=>1, a=>2' is
668        // "a"=>"1"); the old arm replaced it and the comment claimed
669        // last-write-wins matched PG.
670        if out.iter().any(|(k, _)| k == &key) {
671            // keep the first
672        } else {
673            out.push((key, val_token));
674        }
675        skip_ws(bytes, &mut i);
676        if i >= bytes.len() {
677            break;
678        }
679        if bytes[i] == b',' {
680            i += 1;
681            skip_ws(bytes, &mut i);
682            continue;
683        }
684        return None;
685    }
686    Some(out)
687}
688
689/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
690/// form `"k"=>"v"` (keys and non-NULL values always quoted;
691/// NULL token is bare).
692pub(crate) fn format_hstore_str(
693    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
694) -> alloc::string::String {
695    let mut out = alloc::string::String::new();
696    for (i, (k, v)) in pairs.iter().enumerate() {
697        if i > 0 {
698            out.push_str(", ");
699        }
700        out.push('"');
701        out.push_str(k);
702        out.push_str("\"=>");
703        match v {
704            None => out.push_str("NULL"),
705            Some(val) => {
706                out.push('"');
707                out.push_str(val);
708                out.push('"');
709            }
710        }
711    }
712    out
713}
714
715/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
716/// share the single hstore renderer.
717pub fn format_hstore_text(
718    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
719) -> alloc::string::String {
720    format_hstore_str(pairs)
721}
722
723// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
724
725/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
726/// per-row token lists. Returns Err on shape mismatch.
727pub(crate) fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
728    let s = s.trim();
729    let outer = s
730        .strip_prefix('{')
731        .and_then(|x| x.strip_suffix('}'))
732        .ok_or("missing outer '{...}' braces")?;
733    let trimmed = outer.trim();
734    if trimmed.is_empty() {
735        return Ok(Vec::new());
736    }
737    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
738    let mut i = 0;
739    let bytes = trimmed.as_bytes();
740    while i < bytes.len() {
741        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
742            i += 1;
743        }
744        if i >= bytes.len() {
745            break;
746        }
747        if bytes[i] != b'{' {
748            return Err("expected '{' opening a row");
749        }
750        i += 1;
751        let row_start = i;
752        let mut depth = 1;
753        while i < bytes.len() && depth > 0 {
754            match bytes[i] {
755                b'{' => depth += 1,
756                b'}' => depth -= 1,
757                _ => {}
758            }
759            if depth > 0 {
760                i += 1;
761            }
762        }
763        if depth != 0 {
764            return Err("unbalanced '{...}' in row");
765        }
766        let row_text = &trimmed[row_start..i];
767        i += 1;
768        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
769            Vec::new()
770        } else {
771            row_text.split(',').map(|t| t.trim().to_string()).collect()
772        };
773        rows.push(cells);
774    }
775    if let Some(first) = rows.first() {
776        let cols = first.len();
777        for r in &rows {
778            if r.len() != cols {
779                return Err("ragged 2D array (rows have different column counts)");
780            }
781        }
782    }
783    Ok(rows)
784}
785
786pub(crate) fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
787    let raw = split_2d_literal(s)?;
788    raw.into_iter()
789        .map(|row| {
790            row.into_iter()
791                .map(|cell| {
792                    if cell.eq_ignore_ascii_case("NULL") {
793                        Ok(None)
794                    } else {
795                        cell.parse::<i32>()
796                            .map(Some)
797                            .map_err(|_| "invalid int element")
798                    }
799                })
800                .collect()
801        })
802        .collect()
803}
804
805pub(crate) fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
806    let raw = split_2d_literal(s)?;
807    raw.into_iter()
808        .map(|row| {
809            row.into_iter()
810                .map(|cell| {
811                    if cell.eq_ignore_ascii_case("NULL") {
812                        Ok(None)
813                    } else {
814                        cell.parse::<i64>()
815                            .map(Some)
816                            .map_err(|_| "invalid bigint element")
817                    }
818                })
819                .collect()
820        })
821        .collect()
822}
823
824pub(crate) fn parse_text_2d_literal(
825    s: &str,
826) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
827    let raw = split_2d_literal(s)?;
828    Ok(raw
829        .into_iter()
830        .map(|row| {
831            row.into_iter()
832                .map(|cell| {
833                    if cell.eq_ignore_ascii_case("NULL") {
834                        None
835                    } else {
836                        Some(cell.trim_matches('"').to_string())
837                    }
838                })
839                .collect()
840        })
841        .collect())
842}
843
844pub(crate) fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
845    let mut out = alloc::string::String::from("{");
846    for (i, row) in rows.iter().enumerate() {
847        if i > 0 {
848            out.push(',');
849        }
850        out.push('{');
851        for (j, cell) in row.iter().enumerate() {
852            if j > 0 {
853                out.push(',');
854            }
855            match cell {
856                None => out.push_str("NULL"),
857                Some(n) => out.push_str(&alloc::format!("{n}")),
858            }
859        }
860        out.push('}');
861    }
862    out.push('}');
863    out
864}
865
866pub(crate) fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
867    let mut out = alloc::string::String::from("{");
868    for (i, row) in rows.iter().enumerate() {
869        if i > 0 {
870            out.push(',');
871        }
872        out.push('{');
873        for (j, cell) in row.iter().enumerate() {
874            if j > 0 {
875                out.push(',');
876            }
877            match cell {
878                None => out.push_str("NULL"),
879                Some(n) => out.push_str(&alloc::format!("{n}")),
880            }
881        }
882        out.push('}');
883    }
884    out.push('}');
885    out
886}
887
888pub(crate) fn format_text_2d_text(
889    rows: &[Vec<Option<alloc::string::String>>],
890) -> alloc::string::String {
891    let mut out = alloc::string::String::from("{");
892    for (i, row) in rows.iter().enumerate() {
893        if i > 0 {
894            out.push(',');
895        }
896        out.push('{');
897        for (j, cell) in row.iter().enumerate() {
898            if j > 0 {
899                out.push(',');
900            }
901            match cell {
902                None => out.push_str("NULL"),
903                Some(s) => out.push_str(s),
904            }
905        }
906        out.push('}');
907    }
908    out.push('}');
909    out
910}
911
912/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
913/// share the single 2D-array renderer.
914pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
915    format_int_2d_text(rows)
916}
917pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
918    format_bigint_2d_text(rows)
919}
920pub fn format_text_2d_text_pub(
921    rows: &[Vec<Option<alloc::string::String>>],
922) -> alloc::string::String {
923    format_text_2d_text(rows)
924}
925
926/// v7.39 (read01 round 75) — `bool[][]` external form. A BOOL element prints as
927/// `t` / `f` INSIDE an array (and `true` / `false` outside it) — the whole reason
928/// this type exists.
929#[must_use]
930pub fn format_bool_2d_text_pub(rows: &[Vec<Option<bool>>]) -> alloc::string::String {
931    use core::fmt::Write as _;
932    let mut out = alloc::string::String::from("{");
933    for (i, row) in rows.iter().enumerate() {
934        if i > 0 {
935            out.push(',');
936        }
937        out.push('{');
938        for (j, cell) in row.iter().enumerate() {
939            if j > 0 {
940                out.push(',');
941            }
942            let _ = match cell {
943                None => write!(out, "NULL"),
944                Some(true) => write!(out, "t"),
945                Some(false) => write!(out, "f"),
946            };
947        }
948        out.push('}');
949    }
950    out.push('}');
951    out
952}
953
954/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
955/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
956/// `'empty'`. Lower / upper may be empty (unbounded). Returns
957/// `None` on any parse failure; caller surfaces as hard error.
958/// v7.38 (read01 U26) — PG range canonicalization, shared by the
959/// `int4range(...)` constructors and the `'...'::int4range` text-input
960/// path so both agree. PG forces an infinite (missing) bound to be
961/// exclusive, then for DISCRETE element kinds (int4/int8/date) rewrites
962/// to the `[)` form: an exclusive lower bumps to inclusive lower+1, an
963/// inclusive upper bumps to exclusive upper+1 — so `[1,3]` becomes
964/// `[1,4)`. Continuous kinds (num/ts/tstz) keep their bounds. Returns
965/// the canonical `(lower, upper, lower_inc, upper_inc, empty)`, or
966/// v7.38 — the canonical `[)` form of a range's bounds:
967/// `(lower, upper, lower_inc, upper_inc, empty)`.
968pub(crate) type CanonRangeBounds = (
969    Option<Value<'static>>,
970    Option<Value<'static>>,
971    bool,
972    bool,
973    bool,
974);
975
976/// `None` if a discrete successor overflows the element type.
977pub(crate) fn canonicalize_range_bounds(
978    kind: spg_storage::RangeKind,
979    lower: Option<Value<'static>>,
980    upper: Option<Value<'static>>,
981    lower_inc: bool,
982    upper_inc: bool,
983) -> Option<CanonRangeBounds> {
984    use spg_storage::RangeKind as K;
985    // An infinite bound is always exclusive.
986    let mut lower_inc = lower.is_some() && lower_inc;
987    let mut upper_inc = upper.is_some() && upper_inc;
988    let mut lower = lower;
989    let mut upper = upper;
990    if matches!(kind, K::Int4 | K::Int8 | K::Date) {
991        fn succ(v: Value<'static>) -> Option<Value<'static>> {
992            Some(match v {
993                Value::Int(n) => Value::Int(n.checked_add(1)?),
994                Value::BigInt(n) => Value::BigInt(n.checked_add(1)?),
995                Value::Date(d) => Value::Date(d.checked_add(1)?),
996                other => other,
997            })
998        }
999        if let Some(l) = lower {
1000            lower = Some(if lower_inc { l } else { succ(l)? });
1001            lower_inc = true;
1002        }
1003        if let Some(u) = upper {
1004            upper = Some(if upper_inc { succ(u)? } else { u });
1005            upper_inc = false;
1006        }
1007    }
1008    // Equal bounds that don't include both ends collapse to 'empty'.
1009    let empty = match (&lower, &upper) {
1010        (Some(l), Some(u)) => l == u && !(lower_inc && upper_inc),
1011        _ => false,
1012    };
1013    Some((lower, upper, lower_inc, upper_inc, empty))
1014}
1015
1016/// v7.39 (read01 rangetypes.c) — the two failure classes of range text
1017/// input, mapping to PG's distinct errors (22P02 malformed vs 22000
1018/// misordered bounds).
1019pub(crate) enum RangeParseError {
1020    Malformed,
1021    Misordered,
1022    /// v7.39 (round 256) — the bracket/comma STRUCTURE parsed, but a
1023    /// bound is not a value of the element type. PG reports the
1024    /// element's own input error here (`invalid input syntax for type
1025    /// integer: "a"`), reserving "malformed range literal" for a
1026    /// structural problem — probed live on both shapes.
1027    BadElement(alloc::string::String),
1028}
1029
1030/// v7.39 (round 256) — the PG name of a range type's ELEMENT type, used
1031/// when a bound fails to parse (`invalid input syntax for type integer`).
1032fn range_element_type_name(kind: spg_storage::RangeKind) -> &'static str {
1033    match kind {
1034        spg_storage::RangeKind::Int4 => "integer",
1035        spg_storage::RangeKind::Int8 => "bigint",
1036        spg_storage::RangeKind::Num => "numeric",
1037        spg_storage::RangeKind::Ts => "timestamp",
1038        spg_storage::RangeKind::TsTz => "timestamp with time zone",
1039        spg_storage::RangeKind::Date => "date",
1040    }
1041}
1042
1043/// True when both bounds are present and lower sorts after upper —
1044/// PG rejects the range before canonicalization.
1045pub(crate) fn range_bounds_misordered(
1046    lower: &Option<Value<'static>>,
1047    upper: &Option<Value<'static>>,
1048) -> bool {
1049    match (lower, upper) {
1050        (Some(l), Some(u)) => crate::orderby::value_cmp(l, u) == core::cmp::Ordering::Greater,
1051        _ => false,
1052    }
1053}
1054
1055pub(crate) fn parse_range_str(
1056    s: &str,
1057    kind: spg_storage::RangeKind,
1058) -> Result<Value<'static>, RangeParseError> {
1059    let s = s.trim();
1060    if s.eq_ignore_ascii_case("empty") {
1061        return Ok(Value::Range {
1062            kind,
1063            lower: None,
1064            upper: None,
1065            lower_inc: false,
1066            upper_inc: false,
1067            empty: true,
1068        });
1069    }
1070    let bytes = s.as_bytes();
1071    if bytes.len() < 3 {
1072        return Err(RangeParseError::Malformed);
1073    }
1074    let lower_inc = match bytes[0] {
1075        b'[' => true,
1076        b'(' => false,
1077        _ => return Err(RangeParseError::Malformed),
1078    };
1079    let upper_inc = match bytes[bytes.len() - 1] {
1080        b']' => true,
1081        b')' => false,
1082        _ => return Err(RangeParseError::Malformed),
1083    };
1084    let inner = &s[1..s.len() - 1];
1085    let (lo_text, up_text) = inner.split_once(',').ok_or(RangeParseError::Malformed)?;
1086    let lower = if lo_text.is_empty() {
1087        None
1088    } else {
1089        Some(
1090            parse_range_element(lo_text, kind)
1091                .ok_or_else(|| RangeParseError::BadElement(lo_text.trim().into()))?,
1092        )
1093    };
1094    let upper = if up_text.is_empty() {
1095        None
1096    } else {
1097        Some(
1098            parse_range_element(up_text, kind)
1099                .ok_or_else(|| RangeParseError::BadElement(up_text.trim().into()))?,
1100        )
1101    };
1102    // v7.39 (read01 rangetypes.c) — PG rejects misordered bounds before
1103    // canonicalization ('[3,1]'::int4range).
1104    if range_bounds_misordered(&lower, &upper) {
1105        return Err(RangeParseError::Misordered);
1106    }
1107    // Canonicalize (discrete `[)` fold + infinite→exclusive) so text
1108    // input agrees with the constructor functions.
1109    let (lower, upper, lower_inc, upper_inc, empty) =
1110        canonicalize_range_bounds(kind, lower, upper, lower_inc, upper_inc)
1111            .ok_or(RangeParseError::Malformed)?;
1112    Ok(Value::Range {
1113        kind,
1114        lower: lower.map(alloc::boxed::Box::new),
1115        upper: upper.map(alloc::boxed::Box::new),
1116        lower_inc,
1117        upper_inc,
1118        empty,
1119    })
1120}
1121
1122/// v7.37.5 δ — parse a PG multirange external form into a Vec of
1123/// `RangeSpan`. Grammar: `{}` empty, `{range1,range2,...}` with
1124/// each range in canonical `[/(/]/)` brackets. Empty subranges
1125/// (`empty`) are accepted but get dropped on round-trip per PG
1126/// semantics. The bounds parser reuses `parse_range_str` by
1127/// wrapping each subrange in the parent kind.
1128pub(crate) fn parse_multirange_str(
1129    s: &str,
1130    kind: spg_storage::RangeKind,
1131) -> Option<Vec<spg_storage::RangeSpan>> {
1132    let s = s.trim();
1133    let inner = s.strip_prefix('{').and_then(|x| x.strip_suffix('}'))?;
1134    let inner = inner.trim();
1135    if inner.is_empty() {
1136        return Some(Vec::new());
1137    }
1138    // Split the inner on commas that sit *between* ranges — not the
1139    // commas inside `[a,b)`. Walk depth: bump on `[` / `(`, drop on
1140    // `]` / `)`. Commas at depth 0 are range separators.
1141    let mut spans: Vec<spg_storage::RangeSpan> = Vec::new();
1142    let bytes = inner.as_bytes();
1143    let mut depth: i32 = 0;
1144    let mut start = 0usize;
1145    for i in 0..=bytes.len() {
1146        let cut = i == bytes.len() || (depth == 0 && bytes[i] == b',');
1147        if !cut {
1148            match bytes.get(i) {
1149                Some(b'[') | Some(b'(') => depth += 1,
1150                Some(b']') | Some(b')') => depth -= 1,
1151                _ => {}
1152            }
1153            continue;
1154        }
1155        let piece = inner[start..i].trim();
1156        if piece.is_empty() {
1157            return None;
1158        }
1159        let r = parse_range_str(piece, kind).ok()?;
1160        let Value::Range {
1161            lower,
1162            upper,
1163            lower_inc,
1164            upper_inc,
1165            empty,
1166            ..
1167        } = r
1168        else {
1169            return None;
1170        };
1171        spans.push(spg_storage::RangeSpan {
1172            lower,
1173            upper,
1174            lower_inc,
1175            upper_inc,
1176            empty,
1177        });
1178        start = i + 1;
1179    }
1180    Some(spans)
1181}
1182
1183/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
1184/// the matching element Value for the RangeKind.
1185/// "+HH[:MM]" tail (without the sign, caller split on '+') → seconds east.
1186fn parse_hhmm_offset_secs(off: &str) -> Option<i32> {
1187    let (h, m) = match off.split_once(':') {
1188        Some((h, m)) => (h, m),
1189        None => (off, "0"),
1190    };
1191    let h: i32 = h.parse().ok()?;
1192    let m: i32 = m.parse().ok()?;
1193    if !(0..=15).contains(&h) || !(0..60).contains(&m) {
1194        return None;
1195    }
1196    Some(h * 3600 + m * 60)
1197}
1198
1199/// v7.39 (read01 regproc.c) — builtin type name (or alias) → OID, the
1200/// resolve half of regtype input. Mirrors the scalar map format_type
1201/// renders; extend both together.
1202pub(crate) fn regtype_name_to_oid(name: &str) -> Option<i64> {
1203    // v7.39 (round 621) — `integer[]` resolves to its array OID. Without this
1204    // `'integer[]'::regtype` was refused as `invalid input syntax for type
1205    // oid`, the mirror of the OID-to-name gap above.
1206    if let Some(base) = name.trim().strip_suffix("[]") {
1207        return array_oid_for_element(regtype_name_to_oid(base)?);
1208    }
1209    Some(match name.trim() {
1210        "bool" | "boolean" => 16,
1211        "bytea" => 17,
1212        "name" => 19,
1213        "int8" | "bigint" => 20,
1214        "int2" | "smallint" => 21,
1215        "int4" | "int" | "integer" => 23,
1216        "text" => 25,
1217        "oid" => 26,
1218        "json" => 114,
1219        "xml" => 142,
1220        "float4" | "real" => 700,
1221        "float8" | "double precision" => 701,
1222        "cidr" => 650,
1223        "inet" => 869,
1224        "macaddr" => 829,
1225        "macaddr8" => 774,
1226        "money" => 790,
1227        "bpchar" | "char" | "character" => 1042,
1228        "varchar" | "character varying" => 1043,
1229        "date" => 1082,
1230        "time" | "time without time zone" => 1083,
1231        "timestamp" | "timestamp without time zone" => 1114,
1232        "timestamptz" | "timestamp with time zone" => 1184,
1233        "interval" => 1186,
1234        "timetz" | "time with time zone" => 1266,
1235        "numeric" | "decimal" => 1700,
1236        "uuid" => 2950,
1237        "jsonb" => 3802,
1238        "tsvector" => 3614,
1239        "tsquery" => 3615,
1240        "pg_lsn" => 3220,
1241        "regtype" => 2206,
1242        "regclass" => 2205,
1243        "regproc" => 24,
1244        // v7.39 (round 640) — `'xid'::regtype` answered `type "xid" does
1245        // not exist` while `NULL::xid` resolved, because the two go
1246        // through different tables. Same three row-header types
1247        // `pg_attribute` names.
1248        "xid" => 28,
1249        "xid8" => 5069,
1250        "tid" => 27,
1251        "cid" => 29,
1252        _ => return None,
1253    })
1254}
1255
1256/// Type name (or alias) → PG's canonical spelling ('int4' → 'integer'),
1257/// via the two builtin OID maps; `None` when unknown. Handles a `[]`
1258/// array suffix.
1259pub(crate) fn regtype_canonical_name(name: &str) -> Option<alloc::string::String> {
1260    let t = name.trim();
1261    if let Some(base) = t.strip_suffix("[]") {
1262        let inner = regtype_canonical_name(base)?;
1263        return Some(alloc::format!("{inner}[]"));
1264    }
1265    // PG's internal array-type spelling ('_int4' = int4[]).
1266    if let Some(base) = t.strip_prefix('_') {
1267        let inner = regtype_canonical_name(base)?;
1268        return Some(alloc::format!("{inner}[]"));
1269    }
1270    let oid = regtype_name_to_oid(&t.to_lowercase())?;
1271    regtype_oid_to_name(oid).map(alloc::string::String::from)
1272}
1273
1274pub(crate) fn parse_range_element(
1275    text: &str,
1276    kind: spg_storage::RangeKind,
1277) -> Option<Value<'static>> {
1278    let text = text.trim().trim_matches('"');
1279    use spg_storage::RangeKind as K;
1280    match kind {
1281        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
1282        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
1283        K::Num => {
1284            // Reuse the Numeric parse via the engine's text-coercion
1285            // path; bail to None on failure.
1286            let dot = text.find('.');
1287            let scale: u16 = dot.map_or(0, |p| (text.len() - p - 1) as u16);
1288            let digits: alloc::string::String = text
1289                .chars()
1290                .filter(|c| *c == '-' || c.is_ascii_digit())
1291                .collect();
1292            let scaled: i128 = digits.parse().ok()?;
1293            Some(Value::Numeric {
1294                scaled,
1295                scale,
1296                kind: spg_storage::NumericKind::Finite,
1297            })
1298        }
1299        K::Ts | K::TsTz => {
1300            // v7.39 (read01 rangetypes.c) — the timestamp parser handles
1301            // datetime[+offset]; a bare date with an offset suffix
1302            // ('2024-01-02+00', legal tstz input) parses as its midnight.
1303            crate::eval::parse_timestamp_literal(text)
1304                .or_else(|| {
1305                    let (date_part, off) = text.split_once(['+'])?;
1306                    if !off.chars().all(|c| c.is_ascii_digit() || c == ':') {
1307                        return None;
1308                    }
1309                    let d = crate::eval::parse_date_literal(date_part.trim())?;
1310                    let mut t = i64::from(d) * 86_400_000_000;
1311                    // Apply the offset (east-positive) back to UTC.
1312                    let secs = parse_hhmm_offset_secs(off)?;
1313                    t -= i64::from(secs) * 1_000_000;
1314                    Some(t)
1315                })
1316                .map(Value::Timestamp)
1317        }
1318        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
1319    }
1320}
1321
1322/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
1323/// PG text form. Re-exported via [`format_range_text`] for use
1324/// from spg-server's pgwire layer.
1325pub fn format_range_text(v: &Value) -> alloc::string::String {
1326    format_range_str(v)
1327}
1328
1329pub(crate) fn format_range_str(v: &Value) -> alloc::string::String {
1330    let Value::Range {
1331        kind,
1332        lower,
1333        upper,
1334        lower_inc,
1335        upper_inc,
1336        empty,
1337    } = v
1338    else {
1339        return alloc::string::String::new();
1340    };
1341    if *empty {
1342        return "empty".into();
1343    }
1344    // v7.39 (read01 rangetypes.c) — tstzrange bounds render with the
1345    // session-UTC offset suffix, as PG's timestamptz_out does. (Named
1346    // session zones inside range elements are a recorded residual with
1347    // the per-value wire SessionTz channel.)
1348    let elem = |v: &Value| -> alloc::string::String {
1349        let base = format_range_element(v);
1350        if matches!(kind, spg_storage::RangeKind::TsTz) && matches!(v, Value::Timestamp(_)) {
1351            alloc::format!("{base}+00")
1352        } else {
1353            base
1354        }
1355    };
1356    let mut out = alloc::string::String::new();
1357    out.push(if *lower_inc { '[' } else { '(' });
1358    if let Some(l) = lower {
1359        out.push_str(&quote_range_bound(&elem(l)));
1360    }
1361    out.push(',');
1362    if let Some(u) = upper {
1363        out.push_str(&quote_range_bound(&elem(u)));
1364    }
1365    out.push(if *upper_inc { ']' } else { ')' });
1366    out
1367}
1368
1369/// PG's `range_out` double-quotes a bound whose text is empty or
1370/// contains a range-syntax metacharacter (`"` `\` `(` `)` `[` `]` `,`)
1371/// or whitespace — so a timestamp bound `2020-01-01 10:00:00` prints
1372/// as `"2020-01-01 10:00:00"` inside the range. `"` and `\` are
1373/// backslash-escaped within the quotes. Numeric / date bounds (no
1374/// spaces) pass through unquoted, matching PG.
1375fn quote_range_bound(s: &str) -> alloc::string::String {
1376    let needs_quote = s.is_empty()
1377        || s.chars()
1378            .any(|c| matches!(c, '"' | '\\' | '(' | ')' | '[' | ']' | ',') || c.is_whitespace());
1379    if !needs_quote {
1380        return s.into();
1381    }
1382    let mut out = alloc::string::String::with_capacity(s.len() + 2);
1383    out.push('"');
1384    for c in s.chars() {
1385        if c == '"' || c == '\\' {
1386            out.push('\\');
1387        }
1388        out.push(c);
1389    }
1390    out.push('"');
1391    out
1392}
1393
1394/// v7.37.5 ε — render a Point as PG canonical `(x,y)`.
1395pub fn format_point(p: spg_storage::Point2D) -> alloc::string::String {
1396    alloc::format!("({},{})", p.x, p.y)
1397}
1398
1399/// v7.37.5 ε — render an Lseg as PG canonical `[(x1,y1),(x2,y2)]`.
1400pub fn format_lseg(p1: spg_storage::Point2D, p2: spg_storage::Point2D) -> alloc::string::String {
1401    alloc::format!("[({},{}),({},{})]", p1.x, p1.y, p2.x, p2.y)
1402}
1403
1404/// v7.37.5 ε — render a Box as PG canonical `(ux,uy),(lx,ly)`.
1405/// PG normalises the corner order on input; we trust the engine's
1406/// constructor has already normalised so the field order here is
1407/// the canonical upper-right + lower-left.
1408pub fn format_pg_box(ur: spg_storage::Point2D, ll: spg_storage::Point2D) -> alloc::string::String {
1409    alloc::format!("({},{}),({},{})", ur.x, ur.y, ll.x, ll.y)
1410}
1411
1412/// v7.37.5 ε — render a Line as PG canonical `{a,b,c}` (Ax+By+C=0).
1413pub fn format_line(a: f64, b: f64, c: f64) -> alloc::string::String {
1414    alloc::format!("{{{},{},{}}}", a, b, c)
1415}
1416
1417/// v7.37.5 ε — render a Circle as PG canonical `<(x,y),r>`.
1418pub fn format_circle(center: spg_storage::Point2D, radius: f64) -> alloc::string::String {
1419    alloc::format!("<({},{}),{}>", center.x, center.y, radius)
1420}
1421
1422/// v7.37.5 ε — render a Path as PG canonical `[(x,y),...]` open
1423/// or `((x,y),...)` closed.
1424pub fn format_path(points: &[spg_storage::Point2D], closed: bool) -> alloc::string::String {
1425    let (open, close) = if closed { ('(', ')') } else { ('[', ']') };
1426    let mut out = alloc::string::String::new();
1427    out.push(open);
1428    for (i, p) in points.iter().enumerate() {
1429        if i > 0 {
1430            out.push(',');
1431        }
1432        out.push_str(&alloc::format!("({},{})", p.x, p.y));
1433    }
1434    out.push(close);
1435    out
1436}
1437
1438/// v7.37.5 ε — render a Polygon as PG canonical `((x,y),...)`.
1439pub fn format_polygon(points: &[spg_storage::Point2D]) -> alloc::string::String {
1440    let mut out = alloc::string::String::new();
1441    out.push('(');
1442    for (i, p) in points.iter().enumerate() {
1443        if i > 0 {
1444            out.push(',');
1445        }
1446        out.push_str(&alloc::format!("({},{})", p.x, p.y));
1447    }
1448    out.push(')');
1449    out
1450}
1451
1452/// v7.37.5 ε — parse a single `(x,y)` or bare `x,y` Point text.
1453/// Surrounding whitespace OK. Returns `None` on malformed input.
1454fn parse_point(s: &str) -> Option<spg_storage::Point2D> {
1455    let s = s.trim();
1456    let inner = s
1457        .strip_prefix('(')
1458        .and_then(|x| x.strip_suffix(')'))
1459        .unwrap_or(s);
1460    let (xs, ys) = inner.split_once(',')?;
1461    let x: f64 = xs.trim().parse().ok()?;
1462    let y: f64 = ys.trim().parse().ok()?;
1463    Some(spg_storage::Point2D { x, y })
1464}
1465
1466/// v7.37.5 ε — parse N points from a comma-separated PG point
1467/// list (`(x1,y1),(x2,y2),...`). Depth-aware split so the commas
1468/// inside each `(...)` aren't taken as separators. Returns `None`
1469/// on malformed input.
1470fn parse_point_list(s: &str) -> Option<Vec<spg_storage::Point2D>> {
1471    let bytes = s.as_bytes();
1472    let mut out: Vec<spg_storage::Point2D> = Vec::new();
1473    let mut depth: i32 = 0;
1474    let mut start = 0usize;
1475    for i in 0..=bytes.len() {
1476        let cut = i == bytes.len() || (depth == 0 && bytes[i] == b',');
1477        if !cut {
1478            match bytes.get(i) {
1479                Some(b'(') | Some(b'[') | Some(b'<') => depth += 1,
1480                Some(b')') | Some(b']') | Some(b'>') => depth -= 1,
1481                _ => {}
1482            }
1483            continue;
1484        }
1485        let piece = s[start..i].trim();
1486        if !piece.is_empty() {
1487            out.push(parse_point(piece)?);
1488        }
1489        start = i + 1;
1490    }
1491    Some(out)
1492}
1493
1494/// v7.37.5 ε — parse Lseg text `[(x1,y1),(x2,y2)]`.
1495pub fn parse_lseg_text(s: &str) -> Option<(spg_storage::Point2D, spg_storage::Point2D)> {
1496    let s = s.trim();
1497    // PG accepts the bracketed `[(x1,y1),(x2,y2)]`, the fully-wrapped
1498    // `((x1,y1),(x2,y2))`, and the bare `(x1,y1),(x2,y2)` spellings.
1499    let inner = s
1500        .strip_prefix('[')
1501        .and_then(|x| x.strip_suffix(']'))
1502        .unwrap_or(s);
1503    let two_points = |v: Option<alloc::vec::Vec<spg_storage::Point2D>>| v.filter(|p| p.len() == 2);
1504    let pts = if let Some(p) = two_points(parse_point_list(inner)) {
1505        p
1506    } else {
1507        inner
1508            .strip_prefix('(')
1509            .and_then(|x| x.strip_suffix(')'))
1510            .and_then(|w| two_points(parse_point_list(w)))?
1511    };
1512    Some((pts[0], pts[1]))
1513}
1514
1515/// v7.37.5 ε — parse Box text `(ux,uy),(lx,ly)`. PG normalises
1516/// any two-corner input into upper-right + lower-left; we do
1517/// the same.
1518pub fn parse_box_text(s: &str) -> Option<(spg_storage::Point2D, spg_storage::Point2D)> {
1519    // PG box input: `(x1,y1),(x2,y2)`, the fully-wrapped `((x1,y1),(x2,y2))`,
1520    // or the bare `x1,y1,x2,y2` (four raw numbers). Try the point-list form,
1521    // then the same list inside one stripped `(...)` layer, then four floats.
1522    let s = s.trim();
1523    let two_points = |v: Option<alloc::vec::Vec<spg_storage::Point2D>>| v.filter(|p| p.len() == 2);
1524    let pts = if let Some(p) = two_points(parse_point_list(s)) {
1525        p
1526    } else if let Some(p) = s
1527        .strip_prefix('(')
1528        .and_then(|x| x.strip_suffix(')'))
1529        .and_then(|inner| two_points(parse_point_list(inner)))
1530    {
1531        p
1532    } else {
1533        let nums: Option<alloc::vec::Vec<f64>> =
1534            s.split(',').map(|t| t.trim().parse::<f64>().ok()).collect();
1535        let nums = nums?;
1536        if nums.len() != 4 {
1537            return None;
1538        }
1539        alloc::vec![
1540            spg_storage::Point2D {
1541                x: nums[0],
1542                y: nums[1]
1543            },
1544            spg_storage::Point2D {
1545                x: nums[2],
1546                y: nums[3]
1547            },
1548        ]
1549    };
1550    if pts.len() != 2 {
1551        return None;
1552    }
1553    let (a, b) = (pts[0], pts[1]);
1554    // Normalise: upper-right has the larger x AND larger y.
1555    let ur = spg_storage::Point2D {
1556        x: a.x.max(b.x),
1557        y: a.y.max(b.y),
1558    };
1559    let ll = spg_storage::Point2D {
1560        x: a.x.min(b.x),
1561        y: a.y.min(b.y),
1562    };
1563    Some((ur, ll))
1564}
1565
1566/// v7.37.5 ε — parse Line text `{a,b,c}`.
1567pub fn parse_line_text(s: &str) -> Option<(f64, f64, f64)> {
1568    let s = s.trim();
1569    if let Some(inner) = s.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
1570        let parts: Vec<&str> = inner.split(',').collect();
1571        if parts.len() != 3 {
1572            return None;
1573        }
1574        let a: f64 = parts[0].trim().parse().ok()?;
1575        let b: f64 = parts[1].trim().parse().ok()?;
1576        // PG rejects A = B = 0 (not a line).
1577        if a == 0.0 && b == 0.0 {
1578            return None;
1579        }
1580        let c: f64 = parts[2].trim().parse().ok()?;
1581        return Some((a, b, c));
1582    }
1583    // v7.39 (read01 geo_ops.c) — the two-point form `((x1,y1),(x2,y2))`
1584    // (or the lseg spellings): PG builds Ax+By+C=0 from the slope —
1585    // vertical is "x = C" (-1, 0, x), horizontal "y = C" (0, -1, y),
1586    // else (m, -1, y - m·x). Coincident points are not a line.
1587    let (p1, p2) = parse_lseg_text(s)?;
1588    if p1.x == p2.x && p1.y == p2.y {
1589        return None;
1590    }
1591    Some(line_from_points(p1, p2))
1592}
1593
1594/// PG's line_construct from two points (geo_ops.c behavior).
1595pub fn line_from_points(p1: spg_storage::Point2D, p2: spg_storage::Point2D) -> (f64, f64, f64) {
1596    if p1.x == p2.x {
1597        (-1.0, 0.0, p1.x)
1598    } else if p1.y == p2.y {
1599        (0.0, -1.0, p1.y)
1600    } else {
1601        let m = (p1.y - p2.y) / (p1.x - p2.x);
1602        let c = p1.y - m * p1.x;
1603        (m, -1.0, if c == 0.0 { 0.0 } else { c })
1604    }
1605}
1606
1607/// v7.37.5 ε — parse Circle text `<(x,y),r>` or `((x,y),r)`.
1608pub fn parse_circle_text(s: &str) -> Option<(spg_storage::Point2D, f64)> {
1609    let s = s.trim();
1610    // PG circle input: `<(x,y),r>`, `((x,y),r)`, `(x,y),r`, or bare `x,y,r`.
1611    let inner = if let Some(i) = s.strip_prefix('<').and_then(|x| x.strip_suffix('>')) {
1612        i
1613    } else if let Some(i) = s.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
1614        i
1615    } else {
1616        s
1617    };
1618    // The last comma at depth 0 splits the center from the radius.
1619    let bytes = inner.as_bytes();
1620    let mut depth = 0i32;
1621    let mut split_at: Option<usize> = None;
1622    for (i, &b) in bytes.iter().enumerate() {
1623        match b {
1624            b'(' | b'[' | b'<' => depth += 1,
1625            b')' | b']' | b'>' => depth -= 1,
1626            b',' if depth == 0 => split_at = Some(i),
1627            _ => {}
1628        }
1629    }
1630    let i = split_at?;
1631    let center = parse_point(&inner[..i])?;
1632    let radius: f64 = inner[i + 1..].trim().parse().ok()?;
1633    Some((center, radius))
1634}
1635
1636/// v7.37.5 ε — parse Path text `[(x,y),...]` (open) or
1637/// `((x,y),...)` (closed). The leading bracket pins openness.
1638pub fn parse_path_text(s: &str) -> Option<(Vec<spg_storage::Point2D>, bool)> {
1639    let s = s.trim();
1640    // `[...]` = open path, `(...)` = closed. A bare point list (no brackets)
1641    // is a closed path in PG. Strip a wrapping layer only when it yields a
1642    // valid point list; otherwise parse the bare list directly as closed
1643    // (stripping unconditionally would mangle `(0,0),(1,1)` into `0,0),(1,1`).
1644    if let Some(i) = s.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
1645        if let Some(pts) = parse_point_list(i) {
1646            return Some((pts, false));
1647        }
1648    }
1649    if let Some(i) = s.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
1650        if let Some(pts) = parse_point_list(i) {
1651            return Some((pts, true));
1652        }
1653    }
1654    parse_point_list(s).map(|pts| (pts, true))
1655}
1656
1657/// v7.37.5 ε — parse Polygon text `((x,y),...)` (implicit closed).
1658pub fn parse_polygon_text(s: &str) -> Option<Vec<spg_storage::Point2D>> {
1659    let s = s.trim();
1660    // The outer parens are optional in PG — `((0,0),(1,1))` and `(0,0),(1,1)`
1661    // both parse. Try stripping one wrapping layer first (the `((...))` form);
1662    // if that doesn't yield a valid point list, parse the bare list directly.
1663    if let Some(inner) = s.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
1664        if let Some(pts) = parse_point_list(inner) {
1665            return Some(pts);
1666        }
1667    }
1668    parse_point_list(s)
1669}
1670
1671/// v7.37.5 ζ-A — render an INET/CIDR address as canonical PG text:
1672/// IPv4: `a.b.c.d/bits`; IPv6: `xxxx:xxxx:.../bits`. The mask is
1673/// elided when it equals the family default (32 for IPv4, 128 for
1674/// IPv6), per PG convention.
1675/// v7.38 (read01) — inet text with the mask ALWAYS shown (`192.168.1.0/32`),
1676/// as PG's `inet::text` / `::varchar` cast renders it (the default display and
1677/// concat omit `/32` and `/128`; this is the cast-path form).
1678pub fn format_inet_full(family: u8, bits: u8, addr: &[u8; 16]) -> alloc::string::String {
1679    let max = if family == 4 { 32 } else { 128 };
1680    let base = format_inet(family, max, addr);
1681    alloc::format!("{base}/{bits}")
1682}
1683
1684pub fn format_inet(family: u8, bits: u8, addr: &[u8; 16]) -> alloc::string::String {
1685    match family {
1686        4 => {
1687            let s = alloc::format!("{}.{}.{}.{}", addr[0], addr[1], addr[2], addr[3]);
1688            if bits == 32 {
1689                s
1690            } else {
1691                alloc::format!("{s}/{bits}")
1692            }
1693        }
1694        6 => {
1695            // v7.38 (read01) — RFC 5952 canonical form: compress the longest
1696            // run of consecutive all-zero groups (leftmost among ties) to `::`,
1697            // but only when that run is ≥ 2 groups. PG always renders this form.
1698            let mut groups = [0u16; 8];
1699            for (i, g) in groups.iter_mut().enumerate() {
1700                *g = (u16::from(addr[i * 2]) << 8) | u16::from(addr[i * 2 + 1]);
1701            }
1702            // v7.38 (read01, T19) — IPv4-mapped IPv6 (`::ffff:0:0/96` range:
1703            // first five groups zero, sixth 0xffff) renders with a dotted-quad
1704            // tail, matching PG (independent of the input spelling).
1705            if groups[..5].iter().all(|&g| g == 0) && groups[5] == 0xffff {
1706                let s =
1707                    alloc::format!("::ffff:{}.{}.{}.{}", addr[12], addr[13], addr[14], addr[15]);
1708                return if bits == 128 {
1709                    s
1710                } else {
1711                    alloc::format!("{s}/{bits}")
1712                };
1713            }
1714            let (mut best_start, mut best_len) = (usize::MAX, 0usize);
1715            let mut i = 0;
1716            while i < 8 {
1717                if groups[i] == 0 {
1718                    let start = i;
1719                    while i < 8 && groups[i] == 0 {
1720                        i += 1;
1721                    }
1722                    if i - start > best_len {
1723                        best_start = start;
1724                        best_len = i - start;
1725                    }
1726                } else {
1727                    i += 1;
1728                }
1729            }
1730            let mut out = alloc::string::String::new();
1731            if best_len >= 2 {
1732                for (idx, g) in groups.iter().enumerate().take(best_start) {
1733                    if idx > 0 {
1734                        out.push(':');
1735                    }
1736                    out.push_str(&alloc::format!("{g:x}"));
1737                }
1738                out.push_str("::");
1739                for (idx, g) in groups.iter().enumerate().skip(best_start + best_len) {
1740                    if idx > best_start + best_len {
1741                        out.push(':');
1742                    }
1743                    out.push_str(&alloc::format!("{g:x}"));
1744                }
1745            } else {
1746                for (idx, g) in groups.iter().enumerate() {
1747                    if idx > 0 {
1748                        out.push(':');
1749                    }
1750                    out.push_str(&alloc::format!("{g:x}"));
1751                }
1752            }
1753            if bits == 128 {
1754                out
1755            } else {
1756                alloc::format!("{out}/{bits}")
1757            }
1758        }
1759        _ => alloc::format!("?invalid-inet-family-{family}"),
1760    }
1761}
1762
1763/// v7.37.5 ζ-A — render a MACADDR (6 bytes) as `aa:bb:cc:dd:ee:ff`.
1764pub fn format_macaddr(m: &[u8; 6]) -> alloc::string::String {
1765    alloc::format!(
1766        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
1767        m[0],
1768        m[1],
1769        m[2],
1770        m[3],
1771        m[4],
1772        m[5]
1773    )
1774}
1775
1776/// v7.37.5 ζ-A — render a MACADDR8 (8 bytes) as `aa:bb:cc:dd:ee:ff:00:11`.
1777pub fn format_macaddr8(m: &[u8; 8]) -> alloc::string::String {
1778    alloc::format!(
1779        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
1780        m[0],
1781        m[1],
1782        m[2],
1783        m[3],
1784        m[4],
1785        m[5],
1786        m[6],
1787        m[7]
1788    )
1789}
1790
1791/// v7.37.5 ζ-A — render a BIT / BIT VARYING as a binary string of
1792/// `'0'` and `'1'` chars (PG canonical text form). Bytes are packed
1793/// big-endian within each byte: the most-significant bit of byte 0
1794/// is bit 0 of the bit string.
1795pub fn format_bit_string(nbits: u32, bytes: &[u8]) -> alloc::string::String {
1796    let mut out = alloc::string::String::with_capacity(nbits as usize);
1797    for i in 0..nbits as usize {
1798        let byte = bytes[i / 8];
1799        let bit = (byte >> (7 - (i % 8))) & 1;
1800        out.push(if bit == 1 { '1' } else { '0' });
1801    }
1802    out
1803}
1804
1805/// MSB-first integer value of a bit string (PG `bit`/`varbit` → integer cast).
1806pub fn bit_string_to_i64(nbits: u32, bytes: &[u8]) -> i64 {
1807    let mut val: i64 = 0;
1808    for i in 0..nbits as usize {
1809        let byte = bytes.get(i / 8).copied().unwrap_or(0);
1810        val = (val << 1) | i64::from((byte >> (7 - (i % 8))) & 1);
1811    }
1812    val
1813}
1814
1815/// v7.37.5 ζ-A — render a MONEY[] in PG external form. Each element
1816/// is the canonical `format_money` output; the array wrapper is
1817/// `{...}` with NULL elements as the literal token `NULL`.
1818pub fn format_money_array(items: &[Option<i64>]) -> alloc::string::String {
1819    let mut out = alloc::string::String::new();
1820    out.push('{');
1821    for (i, item) in items.iter().enumerate() {
1822        if i > 0 {
1823            out.push(',');
1824        }
1825        match item {
1826            None => out.push_str("NULL"),
1827            Some(c) => out.push_str(&crate::eval::format_money(*c)),
1828        }
1829    }
1830    out.push('}');
1831    out
1832}
1833
1834/// v7.37.5 ζ-A — parse PG INET text. Accepts `a.b.c.d[/bits]`
1835/// (IPv4) or `xxxx:xxxx:.../[bits]` (IPv6 colon-separated). The
1836/// mask defaults to 32 (IPv4) / 128 (IPv6) when omitted. Returns
1837/// `(family, bits, addr16)`. `None` on malformed input.
1838pub fn parse_inet_text(s: &str) -> Option<(u8, u8, [u8; 16])> {
1839    let s = s.trim();
1840    let (addr_s, bits_s) = match s.split_once('/') {
1841        Some((a, b)) => (a, Some(b)),
1842        None => (s, None),
1843    };
1844    if addr_s.contains(':') {
1845        // IPv6 — colon-separated up to 8 × u16 hex with optional
1846        // `::` zero-compression. v7.37.5 ship triage broadened the
1847        // pre-7.37.10 8-group-only form to accept canonical PG
1848        // IPv6 abbreviations like `2001:db8::/32`.
1849        let (head, tail) = match addr_s.find("::") {
1850            Some(idx) => (&addr_s[..idx], Some(&addr_s[idx + 2..])),
1851            None => (addr_s, None),
1852        };
1853        let mut head_groups: alloc::vec::Vec<&str> = if head.is_empty() {
1854            alloc::vec::Vec::new()
1855        } else {
1856            head.split(':').collect()
1857        };
1858        let mut tail_groups: alloc::vec::Vec<&str> = match tail {
1859            Some(t) if !t.is_empty() => t.split(':').collect(),
1860            _ => alloc::vec::Vec::new(),
1861        };
1862        // v7.38 (read01, T19) — a trailing dotted-quad (IPv4-in-IPv6, e.g.
1863        // `::ffff:192.168.1.1`, `64:ff9b::192.0.2.1`) fills the last two 16-bit
1864        // words. It is always the final group overall.
1865        let mut dotted_words: Option<[u16; 2]> = None;
1866        if let Some(g) = tail_groups.last().or_else(|| head_groups.last()) {
1867            if g.contains('.') {
1868                let oct: alloc::vec::Vec<&str> = g.split('.').collect();
1869                if oct.len() != 4 {
1870                    return None;
1871                }
1872                let mut b = [0u8; 4];
1873                for (i, o) in oct.iter().enumerate() {
1874                    b[i] = o.parse::<u8>().ok()?;
1875                }
1876                dotted_words = Some([
1877                    (u16::from(b[0]) << 8) | u16::from(b[1]),
1878                    (u16::from(b[2]) << 8) | u16::from(b[3]),
1879                ]);
1880                if !tail_groups.is_empty() {
1881                    tail_groups.pop();
1882                } else {
1883                    head_groups.pop();
1884                }
1885            }
1886        }
1887        let dq = if dotted_words.is_some() { 2 } else { 0 };
1888        let head_len = head_groups.len();
1889        let tail_len = tail_groups.len();
1890        if tail.is_none() {
1891            if head_len + dq != 8 {
1892                return None;
1893            }
1894        } else if head_len + tail_len + dq > 7 {
1895            return None;
1896        }
1897        let mut words = [0u16; 8];
1898        for (i, g) in head_groups.iter().enumerate() {
1899            words[i] = u16::from_str_radix(g, 16).ok()?;
1900        }
1901        // The dotted-quad (if any) occupies the final two words; hex tail groups
1902        // sit just before it.
1903        let trailing_start = 8 - dq - tail_len;
1904        for (i, g) in tail_groups.iter().enumerate() {
1905            words[trailing_start + i] = u16::from_str_radix(g, 16).ok()?;
1906        }
1907        if let Some(dw) = dotted_words {
1908            words[6] = dw[0];
1909            words[7] = dw[1];
1910        }
1911        let mut addr = [0u8; 16];
1912        for (i, w) in words.iter().enumerate() {
1913            addr[i * 2] = (w >> 8) as u8;
1914            addr[i * 2 + 1] = (w & 0xff) as u8;
1915        }
1916        let bits = match bits_s {
1917            Some(b) => b.parse::<u8>().ok().filter(|&n| n <= 128)?,
1918            None => 128,
1919        };
1920        Some((6, bits, addr))
1921    } else {
1922        // IPv4 — `a.b.c.d`.
1923        let parts: alloc::vec::Vec<&str> = addr_s.split('.').collect();
1924        if parts.len() != 4 {
1925            return None;
1926        }
1927        let mut addr = [0u8; 16];
1928        for (i, p) in parts.iter().enumerate() {
1929            addr[i] = p.parse::<u8>().ok()?;
1930        }
1931        let bits = match bits_s {
1932            Some(b) => b.parse::<u8>().ok().filter(|&n| n <= 32)?,
1933            None => 32,
1934        };
1935        Some((4, bits, addr))
1936    }
1937}
1938
1939/// v7.39 (read01 inet_net_pton.c) — parse CIDR text. Beyond the inet
1940/// grammar, cidr accepts ABBREVIATED IPv4 network forms (`10/8`,
1941/// `10.5/16`, `128.1`) zero-filling the missing octets; a missing
1942/// /width defaults to 8×(octets given) for IPv4 and 128 for IPv6.
1943/// Returns Err(()) for "bits set to right of mask" (PG's dedicated
1944/// invalid-cidr-value error), Ok(None) for a plain syntax error.
1945pub fn parse_cidr_text(s: &str) -> Result<Option<(u8, u8, [u8; 16])>, ()> {
1946    let s = s.trim();
1947    let parsed = if !s.contains(':') {
1948        let (addr_s, bits_s) = match s.split_once('/') {
1949            Some((a, b)) => (a, Some(b)),
1950            None => (s, None),
1951        };
1952        let parts: alloc::vec::Vec<&str> = addr_s.split('.').collect();
1953        if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_empty()) {
1954            return Ok(None);
1955        }
1956        let mut addr = [0u8; 16];
1957        for (i, p) in parts.iter().enumerate() {
1958            match p.parse::<u8>() {
1959                Ok(v) => addr[i] = v,
1960                Err(_) => return Ok(None),
1961            }
1962        }
1963        let bits = match bits_s {
1964            Some(b) => match b.parse::<u8>() {
1965                Ok(n) if n <= 32 => n,
1966                _ => return Ok(None),
1967            },
1968            None => (parts.len() as u8) * 8,
1969        };
1970        Some((4u8, bits, addr))
1971    } else {
1972        parse_inet_text(s).map(|(f, b, a)| {
1973            // cidr IPv6 without a /width is the full /128.
1974            (f, if s.contains('/') { b } else { 128 }, a)
1975        })
1976    };
1977    let Some((family, bits, addr)) = parsed else {
1978        return Ok(None);
1979    };
1980    // PG cidr_in rejects host bits to the right of the mask.
1981    let total = if family == 4 { 32u16 } else { 128 };
1982    let nbytes = if family == 4 { 4 } else { 16 };
1983    for byte in 0..nbytes {
1984        let bit_base = (byte as u16) * 8;
1985        let keep = (u16::from(bits)).saturating_sub(bit_base).min(8) as u8;
1986        let mask: u8 = if keep == 0 { 0 } else { 0xffu8 << (8 - keep) };
1987        if addr[byte] & !mask != 0 {
1988            return Err(());
1989        }
1990        if bit_base >= total {
1991            break;
1992        }
1993    }
1994    Ok(Some((family, bits, addr)))
1995}
1996
1997/// v7.37.5 ζ-A — parse PG MACADDR text `aa:bb:cc:dd:ee:ff` (also
1998/// accepts `aa-bb-cc-dd-ee-ff` and unseparated `aabbccddeeff`).
1999pub fn parse_macaddr_text(s: &str) -> Option<[u8; 6]> {
2000    let s = s.trim();
2001    let cleaned: alloc::string::String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
2002    if cleaned.len() != 12 {
2003        return None;
2004    }
2005    let mut out = [0u8; 6];
2006    for i in 0..6 {
2007        out[i] = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).ok()?;
2008    }
2009    Some(out)
2010}
2011
2012/// v7.37.5 ζ-A — parse PG MACADDR8 text.
2013/// v7.39 (read01 pg_lsn.c) — parse PG's `%X/%X` LSN form: two hex halves,
2014/// each at most 8 hex digits (u32), joined `hi << 32 | lo`.
2015/// v7.39 (read01 timestamp.c, sentinel audit) — date days → timestamp
2016/// microseconds with the ±infinity sentinels mapped through (the plain
2017/// multiply overflowed i64 and aborted debug builds).
2018#[must_use]
2019pub fn date_days_to_micros(d: i32) -> i64 {
2020    match d {
2021        i32::MAX => i64::MAX,
2022        i32::MIN => i64::MIN,
2023        _ => i64::from(d) * 86_400_000_000,
2024    }
2025}
2026
2027pub fn parse_pg_lsn_text(s: &str) -> Option<u64> {
2028    let t = s.trim();
2029    let (hi, lo) = t.split_once('/')?;
2030    if hi.is_empty() || lo.is_empty() || hi.len() > 8 || lo.len() > 8 {
2031        return None;
2032    }
2033    let hi = u32::from_str_radix(hi, 16).ok()?;
2034    let lo = u32::from_str_radix(lo, 16).ok()?;
2035    Some((u64::from(hi) << 32) | u64::from(lo))
2036}
2037
2038/// Render an LSN in PG's `%X/%X` form (uppercase hex, no zero-padding).
2039#[must_use]
2040pub fn format_pg_lsn(l: u64) -> alloc::string::String {
2041    alloc::format!("{:X}/{:X}", l >> 32, l & 0xFFFF_FFFF)
2042}
2043
2044pub fn parse_macaddr8_text(s: &str) -> Option<[u8; 8]> {
2045    let s = s.trim();
2046    let cleaned: alloc::string::String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
2047    // v7.39 (read01 mac8.c) — a 6-byte (EUI-48) input converts by
2048    // inserting ff:fe as the 4th/5th octets, like PG's macaddr8_in.
2049    if cleaned.len() == 12 {
2050        let mut six = [0u8; 6];
2051        for i in 0..6 {
2052            six[i] = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).ok()?;
2053        }
2054        return Some([six[0], six[1], six[2], 0xff, 0xfe, six[3], six[4], six[5]]);
2055    }
2056    if cleaned.len() != 16 {
2057        return None;
2058    }
2059    let mut out = [0u8; 8];
2060    for i in 0..8 {
2061        out[i] = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).ok()?;
2062    }
2063    Some(out)
2064}
2065
2066/// v7.37.5 ζ-A — parse PG bit string text (a sequence of `'0'` and
2067/// `'1'` chars). Returns `(nbits, packed_bytes)` — bytes are
2068/// big-endian within each byte (PG canonical).
2069pub fn parse_bit_string_text(s: &str) -> Option<(u32, alloc::vec::Vec<u8>)> {
2070    let s = s.trim();
2071    let nbits = u32::try_from(s.len()).ok()?;
2072    let nbytes = (s.len()).div_ceil(8);
2073    let mut bytes = alloc::vec![0u8; nbytes];
2074    for (i, c) in s.chars().enumerate() {
2075        let bit = match c {
2076            '0' => 0u8,
2077            '1' => 1u8,
2078            _ => return None,
2079        };
2080        if bit == 1 {
2081            bytes[i / 8] |= 1 << (7 - (i % 8));
2082        }
2083    }
2084    Some((nbits, bytes))
2085}
2086
2087/// v7.37.5 δ — render a Multirange in PG external form
2088/// `{[a,b),[c,d)}`. Empty multirange renders as `{}`. Each range
2089/// element is formatted with the same `[/(/]/)` bracket grammar
2090/// as scalar `Value::Range`. RangeSpan carries no `kind` (it
2091/// lives on the parent Multirange), so this routes element
2092/// formatting through `format_range_element` as Value::Range does.
2093pub fn format_multirange(ranges: &[spg_storage::RangeSpan]) -> alloc::string::String {
2094    let mut out = alloc::string::String::new();
2095    out.push('{');
2096    for (i, r) in ranges.iter().enumerate() {
2097        if i > 0 {
2098            out.push(',');
2099        }
2100        if r.empty {
2101            out.push_str("empty");
2102            continue;
2103        }
2104        out.push(if r.lower_inc { '[' } else { '(' });
2105        if let Some(l) = &r.lower {
2106            out.push_str(&quote_range_bound(&format_range_element(l)));
2107        }
2108        out.push(',');
2109        if let Some(u) = &r.upper {
2110            out.push_str(&quote_range_bound(&format_range_element(u)));
2111        }
2112        out.push(if r.upper_inc { ']' } else { ')' });
2113    }
2114    out.push('}');
2115    out
2116}
2117
2118pub(crate) fn format_range_element(v: &Value) -> alloc::string::String {
2119    match v {
2120        Value::Int(n) => alloc::format!("{n}"),
2121        Value::BigInt(n) => alloc::format!("{n}"),
2122        Value::Date(d) => crate::eval::format_date(*d),
2123        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
2124        Value::Numeric {
2125            scaled,
2126            scale,
2127            kind,
2128        } => crate::eval::format_numeric_kind(*kind, *scaled, *scale),
2129        other => alloc::format!("{other:?}"),
2130    }
2131}
2132
2133/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
2134/// cents. Accepts:
2135///   * Optional leading `-` (negative)
2136///   * Optional `$` prefix
2137///   * Integer portion with optional `,` thousands separators
2138///   * Optional `.` followed by 1-2 digits (cents); 1 digit
2139///     auto-pads to 2 (`.5` → 50 cents).
2140///
2141/// Returns None on any parse failure — caller surfaces as hard
2142/// SQL error.
2143pub(crate) fn parse_money_str(s: &str) -> Option<i64> {
2144    // v7.39 (read01 utils/adt, cash.c) — PG's cash_in accepts the sign
2145    // and currency symbol before OR after the digits, accounting
2146    // parentheses for negative, and rounds the first digit past the
2147    // cent (C-locale: fpoint 2, '$', ',').
2148    let mut rest = s.trim();
2149    let mut neg = false;
2150    // Leading currency symbol / sign / accounting paren, in any order
2151    // with whitespace.
2152    loop {
2153        let before = rest;
2154        rest = rest.trim_start();
2155        if let Some(r) = rest.strip_prefix('$') {
2156            rest = r;
2157        } else if let Some(r) = rest.strip_prefix('-') {
2158            neg = true;
2159            rest = r;
2160        } else if let Some(r) = rest.strip_prefix('(') {
2161            neg = true;
2162            rest = r;
2163        } else if let Some(r) = rest.strip_prefix('+') {
2164            rest = r;
2165        }
2166        if rest == before {
2167            break;
2168        }
2169    }
2170    let (int_part, tail) = {
2171        let end = rest
2172            .find(|c: char| !(c.is_ascii_digit() || c == ','))
2173            .unwrap_or(rest.len());
2174        (&rest[..end], &rest[end..])
2175    };
2176    // Validate + strip commas from the integer portion.
2177    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
2178    for b in int_part.bytes() {
2179        match b {
2180            b',' => {}
2181            b'0'..=b'9' => int_digits.push(b as char),
2182            _ => return None,
2183        }
2184    }
2185    if int_digits.is_empty() {
2186        return None;
2187    }
2188    let dollars: i64 = int_digits.parse().ok()?;
2189    // Fractional part: first two digits are cents, the third rounds.
2190    let (mut cents, tail) = match tail.strip_prefix('.') {
2191        None => (0i64, tail),
2192        Some(f) => {
2193            let end = f.find(|c: char| !c.is_ascii_digit()).unwrap_or(f.len());
2194            let (digits, rest_tail) = (&f[..end], &f[end..]);
2195            if digits.is_empty() {
2196                return None;
2197            }
2198            let b = digits.as_bytes();
2199            let mut c = i64::from(b[0] - b'0') * 10;
2200            if b.len() >= 2 {
2201                c += i64::from(b[1] - b'0');
2202            }
2203            if b.len() >= 3 && b[2] >= b'5' {
2204                c += 1;
2205            }
2206            (c, rest_tail)
2207        }
2208    };
2209    // Trailing whitespace / closing paren / sign / currency symbol.
2210    let mut tail = tail;
2211    while !tail.is_empty() {
2212        let t = tail.trim_start();
2213        if let Some(r) = t.strip_prefix(')') {
2214            tail = r;
2215        } else if let Some(r) = t.strip_prefix('-') {
2216            neg = true;
2217            tail = r;
2218        } else if let Some(r) = t.strip_prefix('+') {
2219            tail = r;
2220        } else if let Some(r) = t.strip_prefix('$') {
2221            tail = r;
2222        } else if t.is_empty() {
2223            break;
2224        } else {
2225            return None;
2226        }
2227    }
2228    // cents rounding can carry into the dollar (0.995 -> 1.00).
2229    let carry = cents / 100;
2230    cents %= 100;
2231    let total = dollars
2232        .checked_add(carry)?
2233        .checked_mul(100)?
2234        .checked_add(cents)?;
2235    Some(if neg { -total } else { total })
2236}
2237
2238/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
2239/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
2240///
2241/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
2242/// wired into eval, so a bare `HH:MM:SS` literal would be
2243/// ambiguous. Returns None for any parse failure or out-of-range
2244/// component — caller surfaces as a hard SQL error.
2245///
2246/// Offset range: ±14 hours (±50400 seconds), matching PG's
2247/// internal limit.
2248pub(crate) fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
2249    let s = s.trim();
2250    // Find the offset sign — scan from right since the time part
2251    // never contains '+' / '-' (after the optional fractional dot
2252    // it's all digits and ':').
2253    let bytes = s.as_bytes();
2254    let sign_pos = bytes
2255        .iter()
2256        .enumerate()
2257        .rev()
2258        .find(|&(_, &b)| b == b'+' || b == b'-')
2259        .map(|(i, _)| i)?;
2260    if sign_pos == 0 {
2261        return None; // bare sign — no time component
2262    }
2263    let time_part = &s[..sign_pos];
2264    let offset_part = &s[sign_pos..];
2265    let us = parse_time_str(time_part)?;
2266    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
2267    let offset_body = &offset_part[1..];
2268    // v7.39 (round 253) — PG accepts the compact offset spellings too
2269    // (probed live): `+0230` = 02:30, `+023` = 00:23.
2270    let (hh_str, mm_str) = match offset_body.split_once(':') {
2271        Some((h, m)) => (h, m),
2272        None if offset_body.len() == 4 => offset_body.split_at(2),
2273        None if offset_body.len() == 3 => offset_body.split_at(1),
2274        None => (offset_body, "0"),
2275    };
2276    let hh: i32 = hh_str.parse().ok()?;
2277    let mm: i32 = mm_str.parse().ok()?;
2278    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
2279        return None;
2280    }
2281    let total = sign * (hh * 3600 + mm * 60);
2282    if total.abs() > 50_400 {
2283        return None;
2284    }
2285    Some((us, total))
2286}
2287
2288/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
2289/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
2290/// surfaces as a hard SQL error (no silent truncation, mirrors PG
2291/// `time_in` / `uuid_in` discipline).
2292pub(crate) fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value<'static>, EngineError> {
2293    if n == 0 || (1901..=2155).contains(&n) {
2294        // u16::try_from cannot fail in this range; the cast also
2295        // covers the 0 sentinel.
2296        return Ok(Value::Year(n as u16));
2297    }
2298    Err(EngineError::Eval(EvalError::TypeMismatch {
2299        detail: alloc::format!(
2300            "year value out of range: {n} (column `{col_name}`; \
2301             MySQL accepts 0 or 1901..=2155)"
2302        ),
2303    }))
2304}
2305
2306/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
2307/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
2308///
2309/// Accepts:
2310///   * `HH:MM:SS`            — exact-second precision
2311///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
2312///     with zeros to microseconds
2313///
2314/// Range: hour 0..=24 (`24:00:00` is PG's day-end special, measured
2315/// round 764), minute 0..=59, second 0..=59. Anything else returns
2316/// None — caller surfaces as a hard SQL error (no silent truncation,
2317/// matches PG's `time_in` behaviour).
2318pub(crate) fn parse_time_str(s: &str) -> Option<i64> {
2319    let s = s.trim();
2320    // PG special TIME value: `allballs` is midnight (all zeros).
2321    if s.eq_ignore_ascii_case("allballs") {
2322        return Some(0);
2323    }
2324    let (hms, frac) = match s.split_once('.') {
2325        Some((h, f)) => (h, Some(f)),
2326        None => (s, None),
2327    };
2328    let mut parts = hms.split(':');
2329    let hh: u32 = parts.next()?.parse().ok()?;
2330    let mm: u32 = parts.next()?.parse().ok()?;
2331    // PG accepts the seconds-optional `HH:MM` form for TIME
2332    // (`'10:30'::time` → `10:30:00`); missing seconds default to 0.
2333    let ss: u32 = match parts.next() {
2334        Some(x) => x.parse().ok()?,
2335        None => 0,
2336    };
2337    if parts.next().is_some() {
2338        return None;
2339    }
2340    // PG accepts the end-of-day sentinel `24:00:00` (but nothing past it).
2341    if hh > 24 || mm > 59 || ss > 59 || (hh == 24 && (mm != 0 || ss != 0)) {
2342        return None;
2343    }
2344    let frac_us: i64 = match frac {
2345        None => 0,
2346        Some(f) => {
2347            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
2348                return None;
2349            }
2350            // Right-pad with zeros so '.5' = 500000 µsec.
2351            let mut padded = alloc::string::String::with_capacity(6);
2352            padded.push_str(f);
2353            while padded.len() < 6 {
2354                padded.push('0');
2355            }
2356            padded.parse().ok()?
2357        }
2358    };
2359    if hh == 24 && frac_us != 0 {
2360        return None;
2361    }
2362    Some(
2363        i64::from(hh) * 3_600_000_000
2364            + i64::from(mm) * 60_000_000
2365            + i64::from(ss) * 1_000_000
2366            + frac_us,
2367    )
2368}
2369
2370/// v7.39 (round 272) — PG's declared-typmod bounds: precision 1..=1000
2371/// and scale -1000..=1000 (SPG does not carry a negative scale yet, so
2372/// the lower half is a recorded gap rather than an accepted range).
2373pub(crate) fn numeric_typmod_in_range(precision: u16, scale: i16) -> bool {
2374    (1..=1000).contains(&precision) && (-1000..=1000).contains(&scale)
2375}
2376
2377/// PG's wording for a typmod outside those bounds, given the text
2378/// between the parentheses. `None` when the typmod is fine or the text
2379/// is not a numeric one.
2380pub(crate) fn numeric_typmod_error(name: &str) -> Option<alloc::string::String> {
2381    let lower = name.trim().to_ascii_lowercase();
2382    let (head, rest) = lower.split_once('(')?;
2383    if !matches!(head.trim(), "numeric" | "decimal") {
2384        return None;
2385    }
2386    let args = rest.strip_suffix(')')?;
2387    let mut it = args.split(',').map(str::trim);
2388    let p: i64 = it.next()?.parse().ok()?;
2389    if !(1..=1000).contains(&p) {
2390        return Some(alloc::format!(
2391            "NUMERIC precision {p} must be between 1 and 1000"
2392        ));
2393    }
2394    if let Some(s) = it.next() {
2395        let s: i64 = s.parse().ok()?;
2396        if !(-1000..=1000).contains(&s) {
2397            return Some(alloc::format!(
2398                "NUMERIC scale {s} must be between -1000 and 1000"
2399            ));
2400        }
2401    }
2402    None
2403}
2404
2405/// v7.37.5 ship triage — string-form PG type name → `DataType`
2406/// lookup driving `CastTarget::Named` (the generic typed-cast
2407/// escape). Covers the v7.37.5 γ/δ/ε/ζ-A type-completeness work
2408/// that landed without per-type CastTarget variants. Returns
2409/// `None` for genuinely-unknown idents so the caller can surface
2410/// the existing "unsupported cast target" error.
2411pub(crate) fn type_name_to_data_type(name: &str) -> Option<DataType> {
2412    with_lower_name(name.trim(), type_name_to_data_type_lower)
2413}
2414
2415/// v7.39 (round 607) — lowercase a type NAME without allocating.
2416///
2417/// A cast's target is fixed for the whole statement, but every helper that
2418/// reads it rebuilt its lowercase form for EVERY ROW. `id::REAL` cost 8
2419/// allocations a row where `id::FLOAT` — the same conversion, spelled with a
2420/// name the parser settles into a `CastTarget` variant instead of `Named` —
2421/// cost none, and ran 7.5 ms against 44.6 over 200k rows. Type names are
2422/// short, so the stack buffer covers every spelling that resolves; a longer
2423/// one still answers correctly through the owned path.
2424///
2425/// Only ASCII `A-Z` bytes change, and those never appear inside a multi-byte
2426/// UTF-8 sequence, so lowercasing in place leaves the slice valid UTF-8.
2427pub(crate) fn with_lower_name<R>(name: &str, f: impl FnOnce(&str) -> R) -> R {
2428    const CAP: usize = 64;
2429    if name.len() <= CAP {
2430        let mut buf = [0u8; CAP];
2431        buf[..name.len()].copy_from_slice(name.as_bytes());
2432        buf[..name.len()].make_ascii_lowercase();
2433        if let Ok(s) = core::str::from_utf8(&buf[..name.len()]) {
2434            return f(s);
2435        }
2436    }
2437    f(&name.to_ascii_lowercase())
2438}
2439
2440fn type_name_to_data_type_lower(n: &str) -> Option<DataType> {
2441    // v7.37.5 ship triage — `numeric(p,s)` precision/scale params:
2442    // peel them off and route to a precision-bearing DataType.
2443    if let Some((head, paren)) = n.split_once('(')
2444        && let Some(args) = paren.strip_suffix(')')
2445    {
2446        // v7.39 (round 272) — parsed as u16. At u8 a typmod PG accepts
2447        // (`numeric(1000,999)`) failed to parse and `unwrap_or(0)`
2448        // turned it into the UNCONSTRAINED type, so the cast silently
2449        // did nothing at all rather than reporting anything.
2450        // v7.39 (round 607) — a fixed pair rather than two Vecs. No typmod
2451        // this resolves has a third argument, and both were built for every
2452        // row a `numeric(p,s)` cast touched.
2453        let mut wide: [Option<i32>; 2] = [None, None];
2454        for (slot, s) in wide.iter_mut().zip(args.split(',')) {
2455            *slot = s.trim().parse::<i32>().ok();
2456        }
2457        let nums: [u8; 2] = [
2458            wide[0].and_then(|v| u8::try_from(v).ok()).unwrap_or(0),
2459            wide[1].and_then(|v| u8::try_from(v).ok()).unwrap_or(0),
2460        ];
2461        match head {
2462            // v7.39 (round 281) — `bit(3)` / `varbit(3)` as cast targets.
2463            "bit" => {
2464                return Some(DataType::Bit(
2465                    u32::try_from(wide.first().copied().flatten()?).ok()?,
2466                ));
2467            }
2468            "varbit" | "bit varying" => {
2469                return Some(DataType::BitVarying(
2470                    u32::try_from(wide.first().copied().flatten()?).ok()?,
2471                ));
2472            }
2473            "numeric" | "decimal" => {
2474                let precision = u16::try_from(wide.first().copied().flatten()?).ok()?;
2475                // v7.39 (round 273) — the declared scale is signed.
2476                let scale = i16::try_from(wide.get(1).copied().flatten().unwrap_or(0)).ok()?;
2477                if !numeric_typmod_in_range(precision, scale) {
2478                    return None;
2479                }
2480                return Some(DataType::Numeric { precision, scale });
2481            }
2482            // `varchar(n)` / `char(n)` carry length caps; SPG stores
2483            // these as DataType::Varchar / Char(n). v7.37.5 cast
2484            // recognises both but the cast itself drops the cap
2485            // (Text widening at value time honours the per-row
2486            // length contract already in coerce_value).
2487            "varchar" => {
2488                return Some(DataType::Varchar(nums.first().copied().unwrap_or(0).into()));
2489            }
2490            "char" | "character" => {
2491                return Some(DataType::Char(nums.first().copied().unwrap_or(0).into()));
2492            }
2493            _ => {}
2494        }
2495    }
2496    Some(match n {
2497        "smallint" | "int2" => DataType::SmallInt,
2498        "numeric" | "decimal" => DataType::Numeric {
2499            precision: 0,
2500            scale: 0,
2501        },
2502        // Network/MAC/bit/XML/"char" — all first-class since
2503        // v7.37.5 ζ-A.
2504        "inet" => DataType::Inet,
2505        "cidr" => DataType::Cidr,
2506        "macaddr" => DataType::Macaddr,
2507        "macaddr8" => DataType::Macaddr8,
2508        "pg_lsn" => DataType::PgLsn,
2509        // v7.39 (read01 varbit.c) — the B'...' literal's internal target.
2510        "__bit_literal" => DataType::BitVarying(0),
2511        // v7.39 (round 640) — a transaction id has its own identity now.
2512        // The name used to resolve to `bigint`, which is why
2513        // `pg_typeof(NULL::xid)` said so, `pg_type` could not list oid
2514        // 28, and `CREATE TABLE t (a xid)` was an unknown type.
2515        "xid" => DataType::Xid,
2516        "xid8" => DataType::Xid8,
2517        "bit" => DataType::Bit(0),
2518        "varbit" | "bit varying" => DataType::BitVarying(0),
2519        "xml" => DataType::Xml,
2520        // v7.37 (round 894) — the four names a QUOTED cast could not
2521        // reach. `::tsvector` parses as a keyword arm and works;
2522        // `::"tsvector"` becomes `CastTarget::Named("tsvector")` and lands
2523        // here, where these four were absent, so PG18's own spelling
2524        // answered `type "tsvector" does not exist`. Everything a client
2525        // generates with quoted identifiers — ORMs, pg_dump output — takes
2526        // that path. Enumerated against PG18: of its 75 builtin scalar and
2527        // range types, PG accepts every one quoted and SPG rejected exactly
2528        // these.
2529        "tsvector" => DataType::TsVector,
2530        "tsquery" => DataType::TsQuery,
2531        // `regclass` / `regtype` are the other two PG18 accepts quoted and
2532        // SPG does not, but they have no `DataType` of their own — they
2533        // live as `Value::RegClass` / `Value::RegType` and their casts are
2534        // special-cased at value level. Routing them here would need that
2535        // path, not a name-to-DataType row, so they stay open rather than
2536        // guessed at.
2537        "money" => DataType::Money,
2538        "char1" => DataType::Char1,
2539        // Geometry (v7.37.5 ε).
2540        "point" => DataType::Point,
2541        "lseg" => DataType::Lseg,
2542        "path" => DataType::Path,
2543        "box" => DataType::PgBox,
2544        "polygon" => DataType::Polygon,
2545        "line" => DataType::Line,
2546        "circle" => DataType::Circle,
2547        // Multirange (v7.37.5 δ).
2548        "int4multirange" => DataType::Multirange(spg_storage::RangeKind::Int4),
2549        "int8multirange" => DataType::Multirange(spg_storage::RangeKind::Int8),
2550        "nummultirange" => DataType::Multirange(spg_storage::RangeKind::Num),
2551        "tsmultirange" => DataType::Multirange(spg_storage::RangeKind::Ts),
2552        "tstzmultirange" => DataType::Multirange(spg_storage::RangeKind::TsTz),
2553        "datemultirange" => DataType::Multirange(spg_storage::RangeKind::Date),
2554        // Range scalars(scaffolded in v7.17, casts join here).
2555        "int4range" => DataType::Range(spg_storage::RangeKind::Int4),
2556        "int8range" => DataType::Range(spg_storage::RangeKind::Int8),
2557        "numrange" => DataType::Range(spg_storage::RangeKind::Num),
2558        "tsrange" => DataType::Range(spg_storage::RangeKind::Ts),
2559        "tstzrange" => DataType::Range(spg_storage::RangeKind::TsTz),
2560        "daterange" => DataType::Range(spg_storage::RangeKind::Date),
2561        // Array forms — `::BOOL[]` etc. The parser canonicalises
2562        // postfix `[]` into the `_array` suffix; mirror PG's
2563        // builtin arrays so the cast lands on a typed array Value.
2564        "bool_array" | "boolean_array" => DataType::BoolArray,
2565        "smallint_array" | "int2_array" => DataType::SmallIntArray,
2566        "int_array" | "integer_array" | "int4_array" => DataType::IntArray,
2567        "bigint_array" | "int8_array" => DataType::BigIntArray,
2568        "float_array" | "double_array" | "real_array" | "float8_array" | "float4_array" => {
2569            DataType::FloatArray
2570        }
2571        // Width-suffixed float spellings — SPG has one float
2572        // representation.
2573        "float4" | "real" => DataType::Real,
2574        "float8" | "double precision" | "float" => DataType::Float,
2575        // v7.39 (round 667) — this said "OIDs are plain integers" and
2576        // mapped to BigInt, which is why `pg_typeof(1::oid)` answered
2577        // `bigint`. The VALUE is still a bigint; what changed is that the
2578        // declared type is no longer thrown away. See `DataType::Oid`.
2579        "oid" => DataType::Oid,
2580        // v7.39 (round 694) — the array forms of the system types. PG has
2581        // an array type for every scalar; these five were the ones a cast
2582        // could name and SPG could not answer. `regtype[]` and
2583        // `regclass[]` did not even parse (their scalars have dedicated
2584        // CastTarget variants, so they never reached the postfix `[]`
2585        // handling); `oid[]` and `name[]` parsed and then met `type
2586        // "oid_array" does not exist`.
2587        //
2588        // They land on TextArray rather than a variant apiece for the
2589        // reason the scalars do NOT: a reg* value renders as a NAME, and
2590        // TextArray already carries and renders names. `oid_array` is the
2591        // exception and takes BigIntArray, because an OID renders as its
2592        // number.
2593        "oid_array" => DataType::OidArray,
2594        "name_array" | "regtype_array" | "regclass_array" | "regproc_array" => DataType::TextArray,
2595        // TIME [WITHOUT TIME ZONE] — first-class since the codec
2596        // carries Value::Time; the coerce path parses HH:MM:SS.
2597        "time" | "time without time zone" => DataType::Time,
2598        "timetz" | "time with time zone" => DataType::TimeTz,
2599        // v7.39 (round 780, F31-D1) — `hstore` is a first-class SPG
2600        // type (parser, storage variant, codec and both text
2601        // conversions have existed since v7.17.0) but the type-NAME
2602        // map never listed it, so every wire spelling — a column
2603        // declared `hstore`, a `::hstore` cast — answered
2604        // 'type "hstore" does not exist'.
2605        "hstore" => DataType::Hstore,
2606        "numeric_array" | "decimal_array" => DataType::NumericArray,
2607        "varchar_array" | "character varying_array" | "char_array" | "bpchar_array" => {
2608            DataType::TextArray
2609        }
2610        "text_array" => DataType::TextArray,
2611        "date_array" => DataType::DateArray,
2612        "timestamp_array" => DataType::TimestampArray,
2613        "timestamptz_array" => DataType::TimestamptzArray,
2614        "uuid_array" => DataType::UuidArray,
2615        "json_array" => DataType::JsonArray,
2616        "jsonb_array" => DataType::JsonbArray,
2617        "bytea_array" => DataType::BytesArray,
2618        "interval_array" => DataType::IntervalArray,
2619        "money_array" => DataType::MoneyArray,
2620        // v7.38 (read01) — primitive scalar spellings. These reach here only
2621        // via CastTarget::Named (e.g. the function-style typecast `int4('5')` /
2622        // `text(42)` / `date('2024-01-15')`); the `expr::type` parser path maps
2623        // them to dedicated CastTarget variants and never touches this table.
2624        "int" | "int4" | "integer" => DataType::Int,
2625        "bigint" | "int8" => DataType::BigInt,
2626        "text" => DataType::Text,
2627        // v7.39 (round 291) — PG's identifier type. `CREATE TABLE t (a
2628        // name)` is legal SQL that SPG answered "type \"name\" does not
2629        // exist" to.
2630        "name" => DataType::Name,
2631        "varchar" | "character varying" => DataType::Varchar(0),
2632        // v7.39 (bpchar epic) — bare `char` / `character` is char(1) (SQL
2633        // standard, `'xyz'::char` = 'x'); bare `bpchar` is PG's unlimited
2634        // blank-trimmed type.
2635        "char" | "character" => DataType::Char(1),
2636        "bpchar" => DataType::Char(0),
2637        "bool" | "boolean" => DataType::Bool,
2638        "date" => DataType::Date,
2639        "timestamp" | "timestamp without time zone" => DataType::Timestamp,
2640        "timestamptz" | "timestamp with time zone" => DataType::Timestamptz,
2641        "uuid" => DataType::Uuid,
2642        "json" => DataType::Json,
2643        "jsonb" => DataType::Jsonb,
2644        "bytea" => DataType::Bytes,
2645        "interval" => DataType::Interval,
2646        _ => return None,
2647    })
2648}
2649
2650pub(crate) const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
2651    match t {
2652        ColumnTypeName::SmallInt => DataType::SmallInt,
2653        ColumnTypeName::Int => DataType::Int,
2654        ColumnTypeName::BigInt => DataType::BigInt,
2655        ColumnTypeName::Float => DataType::Float,
2656        ColumnTypeName::Real => DataType::Real,
2657        ColumnTypeName::Text => DataType::Text,
2658        ColumnTypeName::Name => DataType::Name,
2659        ColumnTypeName::Xid => DataType::Xid,
2660        ColumnTypeName::Xid8 => DataType::Xid8,
2661        ColumnTypeName::Oid => DataType::Oid,
2662        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
2663        ColumnTypeName::Char(n) => DataType::Char(n),
2664        ColumnTypeName::Bool => DataType::Bool,
2665        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
2666            dim,
2667            encoding: match encoding {
2668                SqlVecEncoding::F32 => VecEncoding::F32,
2669                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
2670                SqlVecEncoding::F16 => VecEncoding::F16,
2671            },
2672        },
2673        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
2674        ColumnTypeName::Date => DataType::Date,
2675        ColumnTypeName::Timestamp => DataType::Timestamp,
2676        ColumnTypeName::Timestamptz => DataType::Timestamptz,
2677        ColumnTypeName::Json => DataType::Json,
2678        ColumnTypeName::Jsonb => DataType::Jsonb,
2679        ColumnTypeName::Bytes => DataType::Bytes,
2680        ColumnTypeName::TextArray => DataType::TextArray,
2681        ColumnTypeName::IntArray => DataType::IntArray,
2682        ColumnTypeName::BigIntArray => DataType::BigIntArray,
2683        ColumnTypeName::TsVector => DataType::TsVector,
2684        ColumnTypeName::TsQuery => DataType::TsQuery,
2685        ColumnTypeName::Uuid => DataType::Uuid,
2686        ColumnTypeName::Time => DataType::Time,
2687        ColumnTypeName::Year => DataType::Year,
2688        ColumnTypeName::TimeTz => DataType::TimeTz,
2689        ColumnTypeName::Money => DataType::Money,
2690        ColumnTypeName::Range(k) => DataType::Range(match k {
2691            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
2692            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
2693            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
2694            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
2695            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
2696            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
2697        }),
2698        ColumnTypeName::Hstore => DataType::Hstore,
2699        ColumnTypeName::IntArray2D => DataType::IntArray2D,
2700        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
2701        ColumnTypeName::TextArray2D => DataType::TextArray2D,
2702        ColumnTypeName::BoolArray2D => DataType::BoolArray2D,
2703        ColumnTypeName::Interval => DataType::Interval,
2704        ColumnTypeName::IntervalArray => DataType::IntervalArray,
2705        ColumnTypeName::BoolArray => DataType::BoolArray,
2706        ColumnTypeName::SmallIntArray => DataType::SmallIntArray,
2707        ColumnTypeName::FloatArray => DataType::FloatArray,
2708        ColumnTypeName::NumericArray => DataType::NumericArray,
2709        ColumnTypeName::DateArray => DataType::DateArray,
2710        ColumnTypeName::TimestampArray => DataType::TimestampArray,
2711        ColumnTypeName::TimestamptzArray => DataType::TimestamptzArray,
2712        ColumnTypeName::UuidArray => DataType::UuidArray,
2713        ColumnTypeName::JsonArray => DataType::JsonArray,
2714        ColumnTypeName::JsonbArray => DataType::JsonbArray,
2715        ColumnTypeName::BytesArray => DataType::BytesArray,
2716        ColumnTypeName::VarcharArray => DataType::VarcharArray,
2717        ColumnTypeName::CharArray => DataType::CharArray,
2718        ColumnTypeName::Multirange(k) => DataType::Multirange(match k {
2719            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
2720            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
2721            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
2722            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
2723            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
2724            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
2725        }),
2726        ColumnTypeName::Point => DataType::Point,
2727        ColumnTypeName::Lseg => DataType::Lseg,
2728        ColumnTypeName::Path => DataType::Path,
2729        ColumnTypeName::PgBox => DataType::PgBox,
2730        ColumnTypeName::Polygon => DataType::Polygon,
2731        ColumnTypeName::Line => DataType::Line,
2732        ColumnTypeName::Circle => DataType::Circle,
2733        ColumnTypeName::Inet => DataType::Inet,
2734        ColumnTypeName::Cidr => DataType::Cidr,
2735        ColumnTypeName::Macaddr => DataType::Macaddr,
2736        ColumnTypeName::Macaddr8 => DataType::Macaddr8,
2737        ColumnTypeName::Bit(n) => DataType::Bit(n),
2738        ColumnTypeName::BitVarying(n) => DataType::BitVarying(n),
2739        ColumnTypeName::Xml => DataType::Xml,
2740        ColumnTypeName::Char1 => DataType::Char1,
2741        ColumnTypeName::MoneyArray => DataType::MoneyArray,
2742    }
2743}
2744
2745/// Convert an INSERT VALUES expression to a storage Value. Supports literal
2746/// expressions, unary-minus over numeric literals, and pgvector-style
2747/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
2748pub(crate) fn literal_expr_to_value(expr: Expr) -> Result<Value<'static>, EngineError> {
2749    literal_expr_to_value_in(expr, None)
2750}
2751
2752/// v7.39 (read01 round 55) — the catalog-aware form. `cast_value` cannot
2753/// resolve a user-named type (composite / domain / enum) or a regclass on its
2754/// own: those live in the catalog. Without it, `INSERT INTO t VALUES
2755/// (ROW(1,2)::pt)` failed with "unsupported cast target `::pt`" — the whole
2756/// INSERT, so the table stayed empty. Callers that HAVE a catalog pass it;
2757/// the ones that don't (DDL default folding, partition bounds) keep the old
2758/// literal-only behaviour.
2759pub(crate) fn literal_expr_to_value_in(
2760    expr: Expr,
2761    catalog: Option<&spg_storage::Catalog>,
2762) -> Result<Value<'static>, EngineError> {
2763    match expr {
2764        Expr::Literal(l) => Ok(literal_to_value(l)),
2765        Expr::Cast { expr, target } => {
2766            // A catalog-dependent cast target has to go through eval's
2767            // pre-hook, which is the only place that knows the user types.
2768            if catalog.is_some()
2769                && matches!(
2770                    target,
2771                    spg_sql::ast::CastTarget::Named(_) | spg_sql::ast::CastTarget::RegClass
2772                )
2773            {
2774                return eval_expr_with_catalog(Expr::Cast { expr, target }, catalog);
2775            }
2776            let inner_value = literal_expr_to_value_in(*expr, catalog)?;
2777            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
2778        }
2779        Expr::Unary {
2780            op: UnOp::Neg,
2781            expr,
2782        } => match *expr {
2783            Expr::Literal(Literal::Integer(n)) => {
2784                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
2785                // — overflow on negate of i64::MIN is the one edge case.
2786                let neg = n.checked_neg().ok_or_else(|| {
2787                    EngineError::Unsupported("integer literal overflow on negation".into())
2788                })?;
2789                Ok(int_value_for(neg))
2790            }
2791            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
2792            // v7.38 (read01) — a dotted literal is NUMERIC; negate the mantissa.
2793            Expr::Literal(Literal::Numeric { unscaled, scale }) => Ok(Value::Numeric {
2794                scaled: -unscaled,
2795                scale,
2796                kind: spg_storage::NumericKind::Finite,
2797            }),
2798            // v7.38 (read01, T3.C3) — a NUMERIC literal beyond i128; negate by
2799            // flipping the sign of the decimal string, then re-resolve.
2800            Expr::Literal(Literal::NumericBig(ref s)) => {
2801                let flipped = if let Some(rest) = s.strip_prefix('-') {
2802                    rest.to_string()
2803                } else {
2804                    alloc::format!("-{s}")
2805                };
2806                Ok(big_literal_to_value(&flipped))
2807            }
2808            // v7.37.5 ship triage — fold the unary minus through a
2809            // `Cast { Literal, target }` wrapper (`-2::smallint`,
2810            // `-3.14::numeric(10,2)`). We negate the inner literal,
2811            // re-wrap with the same cast, and re-enter the literal
2812            // resolver — the cast path handles the typed result.
2813            Expr::Cast {
2814                expr: inner,
2815                target,
2816            } => {
2817                let negated_inner = match *inner {
2818                    Expr::Literal(Literal::Integer(n)) => {
2819                        let neg = n.checked_neg().ok_or_else(|| {
2820                            EngineError::Unsupported("integer literal overflow on negation".into())
2821                        })?;
2822                        Expr::Literal(Literal::Integer(neg))
2823                    }
2824                    Expr::Literal(Literal::Float(x)) => Expr::Literal(Literal::Float(-x)),
2825                    Expr::Literal(Literal::Numeric { unscaled, scale }) => {
2826                        Expr::Literal(Literal::Numeric {
2827                            unscaled: -unscaled,
2828                            scale,
2829                        })
2830                    }
2831                    // v7.38 (read01, T3.C3) — big NUMERIC literal: flip its sign
2832                    // in the decimal string, re-wrap with the same cast.
2833                    Expr::Literal(Literal::NumericBig(ref s)) => {
2834                        let flipped = if let Some(rest) = s.strip_prefix('-') {
2835                            rest.to_string()
2836                        } else {
2837                            alloc::format!("-{s}")
2838                        };
2839                        Expr::Literal(Literal::NumericBig(flipped))
2840                    }
2841                    other => Expr::Unary {
2842                        op: spg_sql::ast::UnOp::Neg,
2843                        expr: alloc::boxed::Box::new(other),
2844                    },
2845                };
2846                literal_expr_to_value_in(
2847                    Expr::Cast {
2848                        expr: alloc::boxed::Box::new(negated_inner),
2849                        target,
2850                    },
2851                    catalog,
2852                )
2853            }
2854            other => Err(EngineError::Unsupported(alloc::format!(
2855                "unary minus over non-literal expression: {other:?}"
2856            ))),
2857        },
2858        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
2859        // INSERT-time. Each element must reduce to a Value through
2860        // `literal_expr_to_value`; NULL elements become `None`.
2861        // v7.11.13 — deduce shape from element values: all Int →
2862        // IntArray; any BigInt → BigIntArray (widening); any Text
2863        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
2864        // the outer Cast arm before reaching here and re-coerce.
2865        Expr::Array(items) => {
2866            let mut materialised: alloc::vec::Vec<Value<'static>> =
2867                alloc::vec::Vec::with_capacity(items.len());
2868            for elem in &items {
2869                materialised.push(literal_expr_to_value_in(elem.clone(), catalog)?);
2870            }
2871            Ok(crate::describe::upgrade_timestamptz_array(
2872                array_literal_widen(materialised),
2873                &items,
2874                &[],
2875            ))
2876        }
2877        // Any other Expr shape — fall back to a general evaluation
2878        // against an empty row + empty schema. This unblocks the
2879        // app-common patterns where INSERT VALUES carries a
2880        // non-correlated function call:
2881        //   INSERT INTO t VALUES (concat('U-', 42))
2882        //   INSERT INTO t VALUES (now())
2883        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
2884        // Any expression that references a column or `$N`
2885        // placeholder fails cleanly inside `eval_expr` with a
2886        // descriptive error; literals + casts + ARRAY[…] continue
2887        // to take the fast paths above so the hot INSERT path is
2888        // unchanged on the common case.
2889        other => eval_expr_with_catalog(other, catalog),
2890    }
2891}
2892
2893/// v7.39 (read01 round 55) — evaluate a row-free expression, threading the
2894/// catalog when the caller has one so user-named casts resolve.
2895fn eval_expr_with_catalog(
2896    expr: Expr,
2897    catalog: Option<&spg_storage::Catalog>,
2898) -> Result<Value<'static>, EngineError> {
2899    let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
2900    let mut ctx = EvalContext::new(&empty_schema, None);
2901    if let Some(cat) = catalog {
2902        ctx = ctx.with_catalog(cat);
2903    }
2904    let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
2905    crate::eval::eval_expr(&expr, &empty_row, &ctx).map_err(EngineError::Eval)
2906}
2907
2908pub(crate) fn literal_to_value(l: Literal) -> Value<'static> {
2909    match l {
2910        Literal::Integer(n) => int_value_for(n),
2911        Literal::Float(x) => Value::Float(x),
2912        Literal::Numeric { unscaled, scale } => Value::Numeric {
2913            scaled: unscaled,
2914            scale,
2915            kind: spg_storage::NumericKind::Finite,
2916        },
2917        Literal::NumericBig(s) => big_literal_to_value(&s),
2918        Literal::Timestamp { micros, .. } => Value::Timestamp(micros),
2919        Literal::Date { days, .. } => Value::Date(days),
2920        Literal::String(s) => Value::text(s),
2921        Literal::Bool(b) => Value::Bool(b),
2922        Literal::Null => Value::Null,
2923        Literal::Vector(v) => Value::vector(v),
2924        Literal::TextArray(items) => Value::TextArray(items),
2925        Literal::IntArray(items) => Value::IntArray(items),
2926        Literal::BigIntArray(items) => Value::BigIntArray(items),
2927        Literal::Interval {
2928            months,
2929            days,
2930            micros,
2931            ..
2932        } => Value::Interval {
2933            months,
2934            days,
2935            micros,
2936            kind: spg_storage::IntervalKind::Finite,
2937        },
2938    }
2939}
2940
2941/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
2942/// columns will still enforce the right tag downstream — this is just the
2943/// default we synthesise from an unannotated integer literal.
2944pub(crate) fn int_value_for(n: i64) -> Value<'static> {
2945    if let Ok(small) = i32::try_from(n) {
2946        Value::Int(small)
2947    } else {
2948        Value::BigInt(n)
2949    }
2950}
2951
2952/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
2953/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
2954/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
2955/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
2956/// `NULL` is always permitted; the nullability check happens later in storage.
2957/// v7.17.0 Phase 4.4 / v7.39 round 387 (type-fidelity epic P2) — enforce
2958/// the integer range a column's storage `DataType` is too wide to hold.
2959/// Two cases: an UNSIGNED column rejects negatives (Phase 4.4), and a
2960/// TINYINT / MEDIUMINT column (whose storage is the wider SmallInt / Int)
2961/// rejects values outside its real bounds — `INSERT 128 INTO TINYINT` was
2962/// stored silently where MariaDB strict raises ERROR 1264. Called after
2963/// `coerce_value` at each INSERT / UPDATE site. NULL / non-integer cells
2964/// pass through. SPG always presents STRICT_TRANS_TABLES, so out of range
2965/// is an error (the non-strict clamp is a later stage).
2966/// v7.39 (round 424, type-fidelity epic) — apply a MySQL temporal column's
2967/// declared fractional-seconds precision to a value on its way in. MariaDB
2968/// TRUNCATES toward zero to the declared digits — `DATETIME(1)` stores
2969/// `.256789` as `.2`, and a BARE `DATETIME` (precision 0) drops the fraction
2970/// entirely. Called next to `check_unsigned_range` at each INSERT / UPDATE
2971/// site; a column with no declared precision (every PG column) is untouched,
2972/// which is what keeps microsecond behaviour intact there.
2973pub(crate) fn truncate_to_column_fsp(v: Value<'static>, schema: &ColumnSchema) -> Value<'static> {
2974    let Some(fsp) = schema.mysql_fsp else {
2975        return v;
2976    };
2977    if fsp >= 6 {
2978        return v;
2979    }
2980    let scale = 10i64.pow(u32::from(6 - fsp));
2981    // Toward zero, so a negative TIME loses the same digits.
2982    let cut = |micros: i64| (micros / scale) * scale;
2983    match v {
2984        Value::Timestamp(m) => Value::Timestamp(cut(m)),
2985        Value::Time(m) => Value::Time(cut(m)),
2986        other => other,
2987    }
2988}
2989
2990/// v7.39 (round 434) — the integer bounds a column actually accepts: the
2991/// declared MySQL width when one is annotated (TINYINT / MEDIUMINT store in a
2992/// wider tag), otherwise the storage type's own range. Shared by the strict
2993/// range check below and the `INSERT IGNORE` clamp.
2994fn column_int_bounds(schema: &ColumnSchema) -> Option<(i128, i128)> {
2995    if let Some(width) = schema.mysql_int_width {
2996        return Some(match (width, schema.is_unsigned) {
2997            (spg_storage::MysqlIntWidth::Tiny, false) => (-128, 127),
2998            (spg_storage::MysqlIntWidth::Tiny, true) => (0, 255),
2999            (spg_storage::MysqlIntWidth::Small, false) => (-32_768, 32_767),
3000            (spg_storage::MysqlIntWidth::Small, true) => (0, 65_535),
3001            (spg_storage::MysqlIntWidth::Medium, false) => (-8_388_608, 8_388_607),
3002            (spg_storage::MysqlIntWidth::Medium, true) => (0, 16_777_215),
3003            (spg_storage::MysqlIntWidth::Int, false) => (-2_147_483_648, 2_147_483_647),
3004            (spg_storage::MysqlIntWidth::Int, true) => (0, 4_294_967_295),
3005            // v7.39 (round 471, epic P4b) — the whole point of i128 bounds:
3006            // 18446744073709551615 does not fit the i64 these used to be.
3007            (spg_storage::MysqlIntWidth::Big, false) => {
3008                (i128::from(i64::MIN), i128::from(i64::MAX))
3009            }
3010            (spg_storage::MysqlIntWidth::Big, true) => (0, i128::from(u64::MAX)),
3011        });
3012    }
3013    let (lo, hi) = match schema.ty {
3014        DataType::SmallInt => (i128::from(i16::MIN), i128::from(i16::MAX)),
3015        DataType::Int => (i128::from(i32::MIN), i128::from(i32::MAX)),
3016        DataType::BigInt => (i128::from(i64::MIN), i128::from(i64::MAX)),
3017        _ => return None,
3018    };
3019    Some(if schema.is_unsigned {
3020        (0, hi)
3021    } else {
3022        (lo, hi)
3023    })
3024}
3025
3026/// v7.39 (round 434) — bend a value so a MySQL `INSERT IGNORE` can store it.
3027///
3028/// MySQL's IGNORE does two things. Round 406 implemented the first: skip a
3029/// row that violates a unique key. This is the second: per-VALUE errors are
3030/// downgraded to coercions, so a bulk load never stops. Measured on
3031/// MariaDB 11 —
3032///   * a NULL into a NOT NULL column becomes the type's default (0 / '')
3033///   * an out-of-range integer clamps to the declared type's bound
3034///     (99999999999999 into INT → 2147483647)
3035///   * a non-numeric string into an integer column takes its leading numeric
3036///     prefix, or 0 when there is none ('12abc' → 12, 'abc' → 0)
3037///   * an over-long string truncates to the declared length
3038///
3039/// Anything this does not recognise is returned unchanged, so the ordinary
3040/// coercion path still raises its ordinary error. That is deliberate: where
3041/// SPG cannot represent MySQL's answer (a `'0000-00-00'` zero date, an ENUM's
3042/// empty error-member) the statement fails loudly rather than silently
3043/// storing a value MySQL would not have stored.
3044/// v7.38.18 (C12) — what MySQL would have said about a value that
3045/// [`mysql_ignore_fit`] bent, or `None` if it did not bend it.
3046///
3047/// Derived from the before/after pair rather than reported out of the
3048/// conversion, which stays a pure function of value and column.
3049///
3050/// Every code and every wording is from a MySQL 9.7.2 run, not from
3051/// documentation — an application switching on an errno has to see the
3052/// errno it would have seen:
3053///
3054/// ```text
3055/// INSERT INTO w VALUES (1,'toolong')     1265 Data truncated for column 's' at row 1
3056/// INSERT INTO w VALUES ('abc','ok')      1366 Incorrect integer value: 'abc' for column 'i' at row 1
3057/// INSERT INTO w VALUES (99999999999,..)  1264 Out of range value for column 'i' at row 1
3058/// INSERT INTO w (s) VALUES ('ok')        1364 Field 'i' doesn't have a default value
3059/// ```
3060pub(crate) fn mysql_fit_warning(
3061    before: &Value<'_>,
3062    after: &Value<'_>,
3063    schema: &ColumnSchema,
3064    row: usize,
3065    omitted: bool,
3066) -> Option<crate::MysqlWarning> {
3067    if before == after {
3068        return None;
3069    }
3070    let col = &schema.name;
3071    // An omitted NOT NULL column is a different complaint from a value
3072    // that would not fit.
3073    if omitted || before.is_null() {
3074        return Some(crate::MysqlWarning {
3075            level: "Warning",
3076            code: 1364,
3077            message: alloc::format!("Field '{col}' doesn't have a default value"),
3078        });
3079    }
3080    let numeric_col = matches!(
3081        schema.ty,
3082        DataType::SmallInt | DataType::Int | DataType::BigInt | DataType::Float | DataType::Real
3083    );
3084    if numeric_col {
3085        // A string given to a numeric column is 1366; a number that did
3086        // not fit its range is 1264.
3087        return Some(if matches!(before, Value::Text(_) | Value::BpChar(_)) {
3088            crate::MysqlWarning {
3089                level: "Warning",
3090                code: 1366,
3091                message: alloc::format!(
3092                    "Incorrect integer value: '{}' for column '{col}' at row {row}",
3093                    crate::eval::value_to_text(before)
3094                ),
3095            }
3096        } else {
3097            crate::MysqlWarning {
3098                level: "Warning",
3099                code: 1264,
3100                message: alloc::format!("Out of range value for column '{col}' at row {row}"),
3101            }
3102        });
3103    }
3104    Some(crate::MysqlWarning {
3105        level: "Warning",
3106        code: 1265,
3107        message: alloc::format!("Data truncated for column '{col}' at row {row}"),
3108    })
3109}
3110
3111pub(crate) fn mysql_ignore_fit(v: Value<'static>, schema: &ColumnSchema) -> Value<'static> {
3112    if v.is_null() {
3113        if schema.nullable {
3114            return v;
3115        }
3116        // MySQL fills a NOT NULL column with its type's zero value.
3117        return match schema.ty {
3118            DataType::SmallInt | DataType::Int | DataType::BigInt => Value::BigInt(0),
3119            DataType::Float | DataType::Real => Value::Float(0.0),
3120            DataType::Text | DataType::Varchar(_) | DataType::Char(_) => Value::text(""),
3121            _ => v,
3122        };
3123    }
3124    // A string bound for an integer column: MySQL reads the leading numeric
3125    // prefix and calls the rest a truncation warning.
3126    if let Value::Text(ref s) = v
3127        && matches!(
3128            schema.ty,
3129            DataType::SmallInt | DataType::Int | DataType::BigInt
3130        )
3131        && s.trim().parse::<i64>().is_err()
3132    {
3133        return Value::BigInt(leading_numeric_prefix(s));
3134    }
3135    // An out-of-range integer clamps to the column's bound.
3136    let as_int = match v {
3137        Value::SmallInt(n) => Some(i128::from(n)),
3138        Value::Int(n) => Some(i128::from(n)),
3139        Value::BigInt(n) => Some(i128::from(n)),
3140        // v7.39 (round 471) — a BIGINT UNSIGNED cell arrives as Numeric.
3141        Value::Numeric {
3142            scaled, scale: 0, ..
3143        } => Some(scaled),
3144        _ => None,
3145    };
3146    if let Some(n) = as_int
3147        && let Some((lo, hi)) = column_int_bounds(schema)
3148        && (n < lo || n > hi)
3149    {
3150        return int_value_for_column(n.clamp(lo, hi));
3151    }
3152    // An over-long string truncates to the declared length.
3153    if let Value::Text(ref s) = v {
3154        let max = match schema.ty {
3155            DataType::Varchar(m) | DataType::Char(m) if m > 0 => m as usize,
3156            _ => return v,
3157        };
3158        if s.chars().count() > max {
3159            return Value::text(s.chars().take(max).collect::<alloc::string::String>());
3160        }
3161    }
3162    v
3163}
3164
3165/// MySQL's string → integer coercion: take the longest leading numeric
3166/// prefix, read it as a double, and round half AWAY FROM ZERO. Measured on
3167/// MariaDB 11 — `'3.7abc'` → 4, `'2.4'` → 2, `'2.5'` → 3, `'-2.5'` → -3,
3168/// `'1e3x'` → 1000, `'0x10'` → 0 (the prefix is just the leading `0`),
3169/// `'abc'` / `'-'` / `''` → 0.
3170///
3171/// The prefix is a float, not an integer: reading only digits would answer 0
3172/// for `'.5'` where MySQL answers 1.
3173fn leading_numeric_prefix(s: &str) -> i64 {
3174    let t = s.trim_start();
3175    let b = t.as_bytes();
3176    let mut i = 0;
3177    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
3178        i += 1;
3179    }
3180    let int_start = i;
3181    while i < b.len() && b[i].is_ascii_digit() {
3182        i += 1;
3183    }
3184    let mut end = i;
3185    if i < b.len() && b[i] == b'.' {
3186        i += 1;
3187        while i < b.len() && b[i].is_ascii_digit() {
3188            i += 1;
3189        }
3190        // A lone "." after the sign is not a number; digits on either side
3191        // of it are.
3192        if i > int_start + 1 {
3193            end = i;
3194        }
3195    }
3196    // An exponent only counts when it has at least one digit AND a mantissa.
3197    if end > int_start && i < b.len() && (b[i] == b'e' || b[i] == b'E') {
3198        let mut j = i + 1;
3199        if j < b.len() && (b[j] == b'-' || b[j] == b'+') {
3200            j += 1;
3201        }
3202        let digits_start = j;
3203        while j < b.len() && b[j].is_ascii_digit() {
3204            j += 1;
3205        }
3206        if j > digits_start {
3207            end = j;
3208        }
3209    }
3210    let Ok(f) = t[..end].parse::<f64>() else {
3211        return 0;
3212    };
3213    // `f64::round` is already half-away-from-zero, which is MySQL's rule.
3214    let r = f.round();
3215    if r >= i64::MAX as f64 {
3216        i64::MAX
3217    } else if r <= i64::MIN as f64 {
3218        i64::MIN
3219    } else {
3220        r as i64
3221    }
3222}
3223
3224/// v7.39 (round 471) — the Value an integer takes when it may exceed i64.
3225/// Mirrors `eval::u64_as_value`: BigInt while it fits, Numeric (scale 0)
3226/// past it, which is how a BIGINT UNSIGNED cell is stored.
3227fn int_value_for_column(n: i128) -> Value<'static> {
3228    match i64::try_from(n) {
3229        Ok(v) => Value::BigInt(v),
3230        Err(_) => Value::numeric(n, 0),
3231    }
3232}
3233
3234pub(crate) fn check_unsigned_range(
3235    v: &Value,
3236    schema: &ColumnSchema,
3237    position: usize,
3238) -> Result<(), EngineError> {
3239    let n: i128 = match v {
3240        Value::SmallInt(x) => i128::from(*x),
3241        Value::Int(x) => i128::from(*x),
3242        Value::BigInt(x) => i128::from(*x),
3243        // v7.39 (round 471) — a BIGINT UNSIGNED cell arrives as Numeric,
3244        // which is the whole reason the bounds are i128 now.
3245        Value::Numeric { scaled, scale, .. } if *scale == 0 => *scaled,
3246        _ => return Ok(()), // non-integer cells (NULL, default) skip
3247    };
3248    // TINYINT / MEDIUMINT: the storage tag (SmallInt / Int) is wider than
3249    // the declared MySQL type, so the real bounds are enforced here. The
3250    // unsigned variant's 0 lower bound also covers the negative check.
3251    if let Some(width) = schema.mysql_int_width {
3252        // Small / Int are only ever set on an UNSIGNED column (a signed
3253        // SMALLINT / INT keeps its faithful storage tag and no marker); the
3254        // signed arms are unreachable but keep the match total.
3255        // v7.39 (round 471) — one bounds table, not two. The copy here
3256        // drifted out of reach the moment BIGINT UNSIGNED needed i128.
3257        let _ = width;
3258        let (lo, hi) = column_int_bounds(schema).unwrap_or((i128::MIN, i128::MAX));
3259        if n < lo || n > hi {
3260            // MariaDB's wording (SQLSTATE 22003); SPG tracks the column,
3261            // not the multi-row row number the "at row N" suffix carries.
3262            return Err(EngineError::Unsupported(alloc::format!(
3263                "Out of range value for column '{}'",
3264                schema.name
3265            )));
3266        }
3267        return Ok(());
3268    }
3269    // Other columns: reject a negative on any UNSIGNED column (Phase 4.4).
3270    if schema.is_unsigned && n < 0 {
3271        return Err(EngineError::Unsupported(alloc::format!(
3272            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
3273            schema.name
3274        )));
3275    }
3276    Ok(())
3277}
3278
3279/// Coerce a non-empty `TEXT[]` (how an array literal reaches a typed-array
3280/// cast) into a typed array by parsing each element through the existing
3281/// scalar `coerce_value` path. NULL elements pass through. Returns `None` for
3282/// array targets this helper does not cover (leaving the caller's other arms
3283/// or the final type-mismatch to handle it).
3284fn coerce_text_array_to(
3285    items: alloc::vec::Vec<Option<alloc::string::String>>,
3286    target: DataType,
3287    col: &str,
3288) -> Result<Option<Value<'static>>, EngineError> {
3289    let elem_dt = match target {
3290        DataType::BoolArray => DataType::Bool,
3291        DataType::NumericArray => DataType::Numeric {
3292            precision: 0,
3293            scale: 0,
3294        },
3295        DataType::DateArray => DataType::Date,
3296        DataType::TimestampArray => DataType::Timestamp,
3297        DataType::TimestamptzArray => DataType::Timestamptz,
3298        DataType::UuidArray => DataType::Uuid,
3299        // v7.39 (round 326, V43) — INTERVAL[] joined the covered set;
3300        // `'{1 day}'::interval[]` used to fail as a plain type mismatch.
3301        DataType::IntervalArray => DataType::Interval,
3302        _ => return Ok(None),
3303    };
3304    let mut scal: alloc::vec::Vec<Option<Value<'static>>> =
3305        alloc::vec::Vec::with_capacity(items.len());
3306    for item in items {
3307        match item {
3308            None => scal.push(None),
3309            Some(s) => scal.push(Some(coerce_value(Value::text(s), elem_dt, col, 0)?)),
3310        }
3311    }
3312    let out = match target {
3313        DataType::BoolArray => Value::BoolArray(
3314            scal.into_iter()
3315                .map(|o| o.map(|v| matches!(v, Value::Bool(true))))
3316                .collect(),
3317        ),
3318        DataType::NumericArray => Value::NumericArray(
3319            scal.into_iter()
3320                .map(|o| {
3321                    o.map(|v| match v {
3322                        Value::Numeric { scaled, scale, .. } => (scaled, scale),
3323                        _ => (0, 0),
3324                    })
3325                })
3326                .collect(),
3327        ),
3328        DataType::DateArray => Value::DateArray(
3329            scal.into_iter()
3330                .map(|o| {
3331                    o.map(|v| match v {
3332                        Value::Date(d) => d,
3333                        _ => 0,
3334                    })
3335                })
3336                .collect(),
3337        ),
3338        DataType::TimestampArray => Value::TimestampArray(
3339            scal.into_iter()
3340                .map(|o| {
3341                    o.map(|v| match v {
3342                        Value::Timestamp(t) => t,
3343                        _ => 0,
3344                    })
3345                })
3346                .collect(),
3347        ),
3348        DataType::TimestamptzArray => Value::TimestamptzArray(
3349            scal.into_iter()
3350                .map(|o| {
3351                    o.map(|v| match v {
3352                        Value::Timestamp(t) => t,
3353                        _ => 0,
3354                    })
3355                })
3356                .collect(),
3357        ),
3358        DataType::UuidArray => Value::UuidArray(
3359            scal.into_iter()
3360                .map(|o| {
3361                    o.map(|v| match v {
3362                        Value::Uuid(u) => u,
3363                        _ => [0u8; 16],
3364                    })
3365                })
3366                .collect(),
3367        ),
3368        DataType::IntervalArray => Value::IntervalArray(
3369            scal.into_iter()
3370                .map(|o| {
3371                    o.and_then(|v| match v {
3372                        Value::Interval {
3373                            months,
3374                            days,
3375                            micros,
3376                            kind,
3377                        } => Some(spg_storage::IntervalSpan {
3378                            months,
3379                            days,
3380                            micros,
3381                            kind,
3382                        }),
3383                        _ => None,
3384                    })
3385                })
3386                .collect(),
3387        ),
3388        _ => return Ok(None),
3389    };
3390    Ok(Some(out))
3391}
3392
3393/// Parse a PG integer literal in text: decimal, plus the PG 16+ forms —
3394/// radix prefixes (`0x1F` hex / `0o17` octal / `0b101` binary) and `_` digit
3395/// separators (`1_000`). An optional leading sign applies to the magnitude.
3396/// Map a built-in type OID to its SQL-standard name, PG's `format_type`
3397/// / `oid::regtype` spelling (without the typmod). `None` for OIDs SPG
3398/// doesn't recognise (callers render the numeric OID, as PG does for an
3399/// unknown regtype). Shared by the `::regtype` cast and `format_type`.
3400/// v7.39 (read01 utils/adt, format_type.c) — the element OID for a
3401/// standard array type OID (PG's pg_type.typelem for the built-in `_x`
3402/// array types). format_type renders these as `<element>[]`.
3403pub(crate) fn array_oid_element(oid: i64) -> Option<i64> {
3404    Some(match oid {
3405        1000 => 16,   // _bool
3406        1001 => 17,   // _bytea
3407        1002 => 18,   // _char
3408        1003 => 19,   // _name
3409        1016 => 20,   // _int8
3410        1005 => 21,   // _int2
3411        1007 => 23,   // _int4
3412        1009 => 25,   // _text
3413        1028 => 26,   // _oid
3414        199 => 114,   // _json
3415        143 => 142,   // _xml
3416        651 => 650,   // _cidr
3417        1021 => 700,  // _float4
3418        1022 => 701,  // _float8
3419        775 => 774,   // _macaddr8
3420        791 => 790,   // _money
3421        1040 => 829,  // _macaddr
3422        1041 => 869,  // _inet
3423        1014 => 1042, // _bpchar
3424        1015 => 1043, // _varchar
3425        1182 => 1082, // _date
3426        1183 => 1083, // _time
3427        1115 => 1114, // _timestamp
3428        1185 => 1184, // _timestamptz
3429        1187 => 1186, // _interval
3430        1270 => 1266, // _timetz
3431        1561 => 1560, // _bit
3432        1563 => 1562, // _varbit
3433        1231 => 1700, // _numeric
3434        2951 => 2950, // _uuid
3435        3643 => 3614, // _tsvector
3436        3645 => 3615, // _tsquery
3437        3807 => 3802, // _jsonb
3438        _ => return None,
3439    })
3440}
3441
3442/// v7.39 (round 621) — the OID of an ARRAY reads back as `<element>[]`.
3443///
3444/// `1007::regtype` rendered the number `1007` instead of `integer[]`, so a
3445/// column-type query — `atttypid::regtype`, the shape this cast exists for —
3446/// told an ORM the type of every array column was a bare number. The scalar
3447/// OIDs were all there; only the array half was missing, from both directions.
3448pub(crate) fn regtype_oid_to_name_owned(oid: i64) -> Option<alloc::string::String> {
3449    if let Some(scalar) = regtype_oid_to_name(oid) {
3450        return Some(alloc::string::String::from(scalar));
3451    }
3452    let (_, _, elem) = crate::system_catalog::ARRAY_TYPE_OIDS
3453        .iter()
3454        .find(|(arr, _, _)| *arr == oid)?;
3455    Some(alloc::format!("{}[]", regtype_oid_to_name(*elem)?))
3456}
3457
3458/// The array OID whose element is `elem`, for the reverse direction.
3459pub(crate) fn array_oid_for_element(elem: i64) -> Option<i64> {
3460    crate::system_catalog::ARRAY_TYPE_OIDS
3461        .iter()
3462        .find(|(_, _, e)| *e == elem)
3463        .map(|(arr, _, _)| *arr)
3464}
3465
3466pub(crate) fn regtype_oid_to_name(oid: i64) -> Option<&'static str> {
3467    Some(match oid {
3468        4600 => "pg_brin_bloom_summary",
3469        16 => "boolean",
3470        17 => "bytea",
3471        18 => "\"char\"",
3472        19 => "name",
3473        20 => "bigint",
3474        21 => "smallint",
3475        23 => "integer",
3476        25 => "text",
3477        26 => "oid",
3478        // v7.39 (round 640) — the row-header types.
3479        27 => "tid",
3480        28 => "xid",
3481        29 => "cid",
3482        5069 => "xid8",
3483        114 => "json",
3484        142 => "xml",
3485        650 => "cidr",
3486        700 => "real",
3487        701 => "double precision",
3488        774 => "macaddr8",
3489        790 => "money",
3490        829 => "macaddr",
3491        869 => "inet",
3492        1042 => "character",
3493        1043 => "character varying",
3494        1082 => "date",
3495        1083 => "time without time zone",
3496        1114 => "timestamp without time zone",
3497        1184 => "timestamp with time zone",
3498        1186 => "interval",
3499        1266 => "time with time zone",
3500        1560 => "bit",
3501        1562 => "bit varying",
3502        1700 => "numeric",
3503        2950 => "uuid",
3504        3614 => "tsvector",
3505        3615 => "tsquery",
3506        3802 => "jsonb",
3507        3904 => "int4range",
3508        3906 => "numrange",
3509        3908 => "tsrange",
3510        3910 => "tstzrange",
3511        3912 => "daterange",
3512        3926 => "int8range",
3513        _ => return None,
3514    })
3515}
3516
3517pub(crate) fn parse_pg_int(s: &str) -> Option<i64> {
3518    let s = s.trim();
3519    let (neg, rest) = if let Some(r) = s.strip_prefix('-') {
3520        (true, r)
3521    } else if let Some(r) = s.strip_prefix('+') {
3522        (false, r)
3523    } else {
3524        (false, s)
3525    };
3526    // Split off an optional radix prefix (PG 16+: 0x / 0o / 0b), leaving
3527    // the digit portion. PG allows `_` group separators ONLY between two
3528    // digits — a leading/trailing/doubled underscore (`_5`, `5_`, `1__2`)
3529    // or one adjacent to the prefix is "invalid input syntax".
3530    let (radix, digits, has_prefix) =
3531        if let Some(h) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
3532            (16u32, h, true)
3533        } else if let Some(o) = rest.strip_prefix("0o").or_else(|| rest.strip_prefix("0O")) {
3534            (8, o, true)
3535        } else if let Some(b) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) {
3536            (2, b, true)
3537        } else {
3538            (10, rest, false)
3539        };
3540    let db = digits.as_bytes();
3541    // Reject a trailing or doubled underscore anywhere, and a leading
3542    // underscore unless it follows a radix prefix (PG accepts `0x_FF` but
3543    // not `_5` / `5_` / `1__2` / `0xFF_`).
3544    if db.last() == Some(&b'_')
3545        || digits.contains("__")
3546        || (!has_prefix && db.first() == Some(&b'_'))
3547    {
3548        return None;
3549    }
3550    let cleaned: alloc::string::String = digits.chars().filter(|&c| c != '_').collect();
3551    if cleaned.is_empty() {
3552        return None;
3553    }
3554    let mag = i64::from_str_radix(&cleaned, radix).ok()?;
3555    Some(if neg { mag.checked_neg()? } else { mag })
3556}
3557
3558/// v7.38 (read01 P6.38) — well-formedness check for PG's `xml` CONTENT mode.
3559/// Verifies element tags are balanced and properly nested; comments (`<!-- -->`),
3560/// processing instructions (`<? ?>`), CDATA sections, `<!DOCTYPE …>`, plain
3561/// text, self-closing tags and multiple top-level elements are all accepted.
3562/// Attribute values are quote-aware so a `>` inside an attribute doesn't end a
3563/// tag early. This catches the common malformedness (unclosed / mismatched
3564/// tags) libxml2 rejects; deeper libxml2 checks (entity validity, duplicate
3565/// attributes, char legality) are a documented follow-up.
3566fn xml_content_is_well_formed(s: &str) -> bool {
3567    let b = s.as_bytes();
3568    let is_name =
3569        |c: u8| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b':') || c >= 0x80;
3570    let mut stack: alloc::vec::Vec<&[u8]> = alloc::vec::Vec::new();
3571    let mut i = 0;
3572    while i < b.len() {
3573        if b[i] != b'<' {
3574            i += 1;
3575            continue;
3576        }
3577        let rest = &s[i..];
3578        if rest.starts_with("<!--") {
3579            match rest.find("-->") {
3580                Some(p) => i += p + 3,
3581                None => return false,
3582            }
3583        } else if rest.starts_with("<![CDATA[") {
3584            match rest.find("]]>") {
3585                Some(p) => i += p + 3,
3586                None => return false,
3587            }
3588        } else if rest.starts_with("<?") {
3589            match rest.find("?>") {
3590                Some(p) => i += p + 2,
3591                None => return false,
3592            }
3593        } else if rest.starts_with("<!") {
3594            match rest.find('>') {
3595                Some(p) => i += p + 1,
3596                None => return false,
3597            }
3598        } else {
3599            // Element open / close / self-close tag.
3600            let close = i + 1 < b.len() && b[i + 1] == b'/';
3601            let name_start = if close { i + 2 } else { i + 1 };
3602            let mut j = name_start;
3603            while j < b.len() && is_name(b[j]) {
3604                j += 1;
3605            }
3606            if j == name_start {
3607                return false; // `<` not followed by a tag name
3608            }
3609            let name = &b[name_start..j];
3610            // Scan to the matching `>`, skipping quoted attribute values.
3611            let mut k = j;
3612            let mut quote = 0u8;
3613            let mut prev = 0u8;
3614            loop {
3615                if k >= b.len() {
3616                    return false; // unterminated tag
3617                }
3618                let c = b[k];
3619                if quote != 0 {
3620                    if c == quote {
3621                        quote = 0;
3622                    }
3623                } else if c == b'"' || c == b'\'' {
3624                    quote = c;
3625                } else if c == b'>' {
3626                    break;
3627                }
3628                prev = c;
3629                k += 1;
3630            }
3631            let self_closing = prev == b'/';
3632            i = k + 1;
3633            if close {
3634                match stack.pop() {
3635                    Some(top) if top == name => {}
3636                    _ => return false,
3637                }
3638            } else if !self_closing {
3639                stack.push(name);
3640            }
3641        }
3642    }
3643    stack.is_empty()
3644}
3645
3646/// v7.38 (read01) — parse a float8 the way PG's `float8in` does: a
3647/// numeric literal that overflows to ±∞, or a nonzero magnitude that
3648/// underflows to 0, is "out of range" (returns None → the caller errors),
3649/// not a silent Infinity/0. The `inf`/`infinity`/`nan` spellings (a letter
3650/// after the optional sign) are the legitimate special values and pass.
3651pub(crate) fn parse_float8(s: &str) -> Option<f64> {
3652    let t = s.trim();
3653    let parsed = t.parse::<f64>().ok()?;
3654    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
3655    let numeric_looking = body
3656        .bytes()
3657        .next()
3658        .is_some_and(|c| c.is_ascii_digit() || c == b'.');
3659    if numeric_looking {
3660        if parsed.is_infinite() {
3661            return None; // overflow
3662        }
3663        if parsed == 0.0 {
3664            // A mantissa with a nonzero digit that resolves to 0 underflowed.
3665            let mantissa = body.split(['e', 'E']).next().unwrap_or(body);
3666            if mantissa.bytes().any(|c| c.is_ascii_digit() && c != b'0') {
3667                return None;
3668            }
3669        }
3670    }
3671    Some(parsed)
3672}
3673
3674/// v7.38 (read01) — decode PG's external array form (`{a,b,NULL}`) and coerce
3675/// each element to `elem` through `coerce_value`, so element semantics (bool
3676/// spellings, date formats, numeric parsing, float8 range) live in one place.
3677fn decode_array_elems(
3678    s: &str,
3679    elem: DataType,
3680    col_name: &str,
3681    position: usize,
3682) -> Result<Vec<Option<Value<'static>>>, EngineError> {
3683    // v7.39 (round 325, V57) — PG's wording. This path used to answer
3684    // `cannot parse "abc" as an array: TEXT[] literal must be enclosed in
3685    // '{...}'` — SPG's own phrasing, naming TEXT[] even for an INT[]
3686    // column, and differing from what the `::int[]` CAST path already
3687    // said for the very same input.
3688    let raw = decode_text_array_literal(s).map_err(|_| {
3689        EngineError::Eval(EvalError::TypeMismatch {
3690            detail: malformed_array_literal(s),
3691        })
3692    })?;
3693    let mut out = Vec::with_capacity(raw.len());
3694    for e in raw {
3695        match e {
3696            None => out.push(None),
3697            Some(t) => out.push(Some(coerce_value(
3698                Value::text(t),
3699                elem,
3700                col_name,
3701                position,
3702            )?)),
3703        }
3704    }
3705    Ok(out)
3706}
3707
3708/// v7.39 (read01 round 54) — coerce a value whose `data_type()` is None (the
3709/// eval-only variants: RegClass carries an oid + name, Composite a field
3710/// tuple). They used to panic in `coerce_value`.
3711fn coerce_untyped_value(
3712    v: Value<'static>,
3713    expected: DataType,
3714    col_name: &str,
3715    position: usize,
3716) -> Result<Value<'static>, EngineError> {
3717    match (&v, expected) {
3718        // A regclass IS an oid — it coerces to any integer width, and to text
3719        // through its relation name.
3720        //
3721        // v7.39 (round 667) — `DataType::Oid` is listed with BigInt here and
3722        // is not optional. Giving the `oid` name its own DataType turned
3723        // `'text'::regtype::oid` from a coercion into a column of type
3724        // BIGINT into one of type OID, and nine catalog tests went red at
3725        // once. This is the THIRD list that has to name the reg* trio
3726        // together; the other two are the bigint materialiser below and the
3727        // classifier that decides a value is reg-shaped.
3728        (
3729            Value::RegClass(oid, _) | Value::RegProc(oid, _) | Value::RegType(oid, _),
3730            DataType::BigInt | DataType::Oid,
3731        ) => Ok(Value::BigInt(*oid)),
3732        (
3733            Value::RegClass(oid, _) | Value::RegProc(oid, _) | Value::RegType(oid, _),
3734            DataType::Int,
3735        ) => Ok(Value::Int(i32::try_from(*oid).unwrap_or(i32::MAX))),
3736        (
3737            Value::RegClass(_, name) | Value::RegProc(_, name) | Value::RegType(_, name),
3738            DataType::Text,
3739        ) => Ok(Value::text(alloc::string::String::from(name.as_ref()))),
3740        // v7.39 (read01 round 55) — SPG stores a composite-typed column as
3741        // JSON (an object keyed by field name), so a real Composite value —
3742        // which is what `ROW(1,2)::pt` now produces — coerces into it. Before
3743        // this the cast resolved but the INSERT died on "cannot coerce
3744        // Composite(...) to Jsonb".
3745        (Value::Composite(fields), DataType::Jsonb | DataType::Json) => {
3746            let mut obj = alloc::string::String::from("{");
3747            for (i, (name, val)) in fields.iter().enumerate() {
3748                if i > 0 {
3749                    obj.push(',');
3750                }
3751                // Reuse the JSON encoder for the key so escaping is identical.
3752                obj.push_str(&crate::json::value_to_json_text(&Value::text(
3753                    alloc::string::String::from(name.as_str()),
3754                )));
3755                obj.push(':');
3756                obj.push_str(&crate::json::value_to_json_text(val));
3757            }
3758            obj.push('}');
3759            Ok(Value::Json(alloc::borrow::Cow::Owned(obj)))
3760        }
3761        // …and its canonical PG text form for a text column.
3762        (Value::Composite(_), DataType::Text) => Ok(Value::text(crate::eval::value_to_text(&v))),
3763        _ => Err(EngineError::Unsupported(alloc::format!(
3764            "cannot coerce {:?} to {expected:?} for column {col_name:?} (position {position})",
3765            v
3766        ))),
3767    }
3768}
3769
3770/// v7.39 (read01 round 90) — PG's 22P02 for a text value that will not parse as
3771/// the target type: `invalid input syntax for type <T>: "<value>"`. The type
3772/// word is PG's own spelling (`integer`, `double precision`, `boolean`, …).
3773fn invalid_input_syntax(ty: &str, value: &str) -> EngineError {
3774    EngineError::Eval(EvalError::TypeMismatch {
3775        detail: alloc::format!("invalid input syntax for type {ty}: \"{value}\""),
3776    })
3777}
3778
3779/// v7.39 (round 269) — PG quotes the offending source when it has one:
3780/// `"1e40" is out of range for type real`.
3781fn real_out_of_range(value: &str) -> EngineError {
3782    float_out_of_range(value, "real")
3783}
3784
3785/// v7.39 (round 270) — the same for either float width.
3786fn float_out_of_range(value: &str, ty: &str) -> EngineError {
3787    EngineError::Eval(EvalError::TypeMismatch {
3788        detail: alloc::format!("\"{value}\" is out of range for type {ty}"),
3789    })
3790}
3791
3792/// v7.39 (round 270) — a float text that `parse_float8` rejected is
3793/// either not a number at all or a number outside the type's range, and
3794/// PG words the two differently. `parse_float8` already distinguishes
3795/// them internally (it returns None for a numeric-looking infinity or a
3796/// nonzero mantissa that underflowed to zero); this recovers which.
3797fn float_text_error(s: &str, ty: &str) -> EngineError {
3798    let t = s.trim();
3799    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
3800    let numeric_looking = body
3801        .bytes()
3802        .next()
3803        .is_some_and(|c| c.is_ascii_digit() || c == b'.');
3804    if numeric_looking && t.parse::<f64>().is_ok() {
3805        float_out_of_range(t, ty)
3806    } else {
3807        invalid_input_syntax(ty, s)
3808    }
3809}
3810
3811/// Whether a float text names a nonzero value: a mantissa carrying any
3812/// digit other than 0. Underflowing such a source to zero is an error
3813/// in PG, while `'0'` really is zero.
3814fn float_text_is_nonzero(t: &str) -> bool {
3815    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
3816    let mantissa = body.split(['e', 'E']).next().unwrap_or(body);
3817    mantissa.bytes().any(|c| c.is_ascii_digit() && c != b'0')
3818}
3819
3820/// Whether a float text literally spells an infinity, which PG accepts
3821/// as a value rather than treating as an overflow.
3822fn text_is_explicit_infinity(t: &str) -> bool {
3823    let t = t.trim_start_matches(['+', '-']);
3824    t.eq_ignore_ascii_case("inf") || t.eq_ignore_ascii_case("infinity")
3825}
3826
3827/// v7.39 (read01 round 90) — PG splits a failed date/time text into two states:
3828/// a date-shaped string whose fields are out of range (month 13, day 30) is
3829/// 22008 `date/time field value out of range: "X"`; anything not date-shaped is
3830/// 22007 `invalid input syntax for type <T>: "X"`. SPG's parsers return a single
3831/// None, so classify by shape here — runs ONLY on an already-failed parse, so it
3832/// only ever picks between two error strings, never changes behaviour. A string
3833/// of date punctuation (digits, `- / : . space`, `+`, `T`) with at least one
3834/// digit is treated as "well-formed but out of range".
3835fn datetime_parse_error(ty: &str, s: &str) -> EngineError {
3836    let t = s.trim();
3837    let date_shaped = t.chars().any(|c| c.is_ascii_digit())
3838        && t.chars().all(|c| {
3839            c.is_ascii_digit() || matches!(c, '-' | '/' | ':' | '.' | ' ' | '+' | 'T' | 't')
3840        });
3841    let detail = if date_shaped {
3842        alloc::format!("date/time field value out of range: \"{t}\"")
3843    } else {
3844        alloc::format!("invalid input syntax for type {ty}: \"{t}\"")
3845    };
3846    EngineError::Eval(EvalError::TypeMismatch { detail })
3847}
3848
3849/// v7.39 (read01 round 113) — the underlying scalar of a `jsonb` value being
3850/// cast to a numeric or boolean target. PG decodes it first: a JSON number
3851/// becomes an unconstrained NUMERIC (so int targets round half-away, matching
3852/// `2.5::numeric::int` = 3), true/false become bool, `null` becomes SQL NULL.
3853/// A JSON string / array / object is not castable to any scalar target.
3854pub(crate) enum JsonbScalar {
3855    Numeric(Value<'static>),
3856    Bool(bool),
3857    Null,
3858}
3859
3860/// PG's "cannot cast jsonb <kind> to type <target>" (SQLSTATE 22023).
3861pub(crate) fn jsonb_cast_type_error(kind: &str, target: &str) -> EvalError {
3862    EvalError::TypeMismatch {
3863        detail: alloc::format!("cannot cast jsonb {kind} to type {target}"),
3864    }
3865}
3866
3867/// Decode a serialized `jsonb` scalar for a numeric/bool cast. `target` names
3868/// the SQL type only for the error text on the non-scalar kinds.
3869pub(crate) fn jsonb_scalar_for_cast(s: &str, target: &str) -> Result<JsonbScalar, EvalError> {
3870    use crate::json::JsonValue;
3871    match crate::json::parse(s) {
3872        Ok(JsonValue::Null) => Ok(JsonbScalar::Null),
3873        Ok(JsonValue::Bool(b)) => Ok(JsonbScalar::Bool(b)),
3874        // Route the number through the unconstrained NUMERIC input path so the
3875        // integer targets inherit PG's numeric (half-away) rounding + range
3876        // errors, and scientific / big forms are handled once, centrally.
3877        Ok(JsonValue::Number(x)) => {
3878            let num = coerce_value(
3879                Value::text(alloc::format!("{x}")),
3880                DataType::Numeric {
3881                    precision: 0,
3882                    scale: 0,
3883                },
3884                "",
3885                0,
3886            )
3887            .map_err(|e| match e {
3888                EngineError::Eval(ev) => ev,
3889                _ => jsonb_cast_type_error("numeric", target),
3890            })?;
3891            Ok(JsonbScalar::Numeric(num))
3892        }
3893        Ok(JsonValue::NumberText(text)) => {
3894            let num = coerce_value(
3895                Value::text(text),
3896                DataType::Numeric {
3897                    precision: 0,
3898                    scale: 0,
3899                },
3900                "",
3901                0,
3902            )
3903            .map_err(|e| match e {
3904                EngineError::Eval(ev) => ev,
3905                _ => jsonb_cast_type_error("numeric", target),
3906            })?;
3907            Ok(JsonbScalar::Numeric(num))
3908        }
3909        Ok(JsonValue::String(_)) => Err(jsonb_cast_type_error("string", target)),
3910        Ok(JsonValue::Array(_)) => Err(jsonb_cast_type_error("array", target)),
3911        Ok(JsonValue::Object(_)) => Err(jsonb_cast_type_error("object", target)),
3912        Err(_) => Err(jsonb_cast_type_error("value", target)),
3913    }
3914}
3915/// v7.39 (round 263) — normalise a value being written into a COMPOSITE
3916/// column before the generic coercion runs.
3917///
3918/// A composite column stores JSON keyed by FIELD NAME, and the field
3919/// names are PG-observable (`row_to_json(col)` keys by them, probed).
3920/// Two inputs reached the column without ever being labelled by the
3921/// target type:
3922///   * `ROW('elm', 999)` carries the constructor's placeholder names
3923///     `f1`/`f2`, so the stored object had the wrong keys and the read
3924///     side — which looks fields up BY NAME — rebuilt an all-NULL
3925///     record: silent data loss, `(elm,999)` came back as `(,)`.
3926///   * a record TEXT literal (`'("oak ave",111)'`) was stored verbatim,
3927///     which is not JSON at all, so the read side's parse failed and
3928///     field access errored.
3929/// Relabelling through the declared type also COERCES each field to its
3930/// declared type, which is what refuses `ROW('x','notanint')::addr`.
3931/// Returns the value untouched for a non-composite column.
3932pub(crate) fn normalize_composite_for_column(
3933    v: Value<'static>,
3934    col: &ColumnSchema,
3935    catalog: Option<&spg_storage::Catalog>,
3936) -> Result<Value<'static>, EngineError> {
3937    let Some(tname) = col.user_composite_type.as_deref() else {
3938        return Ok(v);
3939    };
3940    if matches!(v, Value::Null) {
3941        return Ok(v);
3942    }
3943    // No catalog in scope degrades to the previous behaviour rather than
3944    // erroring, matching how the read-side rehydration handles it.
3945    let Some(def) = catalog.and_then(|c| c.composite_types().get(tname)) else {
3946        return Ok(v);
3947    };
3948    // An already-labelled Composite still goes through so its fields get
3949    // coerced; a Json value is already in storage form.
3950    if matches!(v, Value::Json(_)) {
3951        return Ok(v);
3952    }
3953    crate::eval::apply_composite_cast_pub(v, def, catalog).map_err(EngineError::Eval)
3954}
3955
3956/// Coerce a `jsonb` value to a scalar numeric/bool `expected`. Returns `None`
3957/// when `expected` is not one of those targets (so the caller falls through to
3958/// the ordinary coercion table).
3959fn try_coerce_json_scalar(
3960    s: &str,
3961    expected: DataType,
3962    col_name: &str,
3963    position: usize,
3964) -> Option<Result<Value<'static>, EngineError>> {
3965    let target = match expected {
3966        DataType::Int => "integer",
3967        DataType::BigInt => "bigint",
3968        DataType::SmallInt => "smallint",
3969        DataType::Numeric { .. } => "numeric",
3970        DataType::Real => "real",
3971        DataType::Float => "double precision",
3972        DataType::Bool => "boolean",
3973        _ => return None,
3974    };
3975    Some(
3976        (|| match jsonb_scalar_for_cast(s, target).map_err(EngineError::Eval)? {
3977            JsonbScalar::Null => Ok(Value::Null),
3978            JsonbScalar::Bool(b) => {
3979                if matches!(expected, DataType::Bool) {
3980                    Ok(Value::Bool(b))
3981                } else {
3982                    Err(EngineError::Eval(jsonb_cast_type_error("boolean", target)))
3983                }
3984            }
3985            JsonbScalar::Numeric(n) => {
3986                if matches!(expected, DataType::Bool) {
3987                    Err(EngineError::Eval(jsonb_cast_type_error("numeric", target)))
3988                } else {
3989                    coerce_value(n, expected, col_name, position)
3990                }
3991            }
3992        })(),
3993    )
3994}
3995
3996/// v7.39 (round 367, M20 P2) — in the MySQL dialect a binary-string
3997/// literal (`0x…` / `X'…'` / `b'…'`, backed by `Value::Bytes`) coerces to
3998/// the target column like MariaDB does: into a BINARY / BLOB column it
3999/// stays bytes (handled by `coerce_value` itself); into a NUMERIC column
4000/// it is the bytes' big-endian integer (`INSERT … VALUES (0x10)` stores
4001/// 16); into a CHAR / VARCHAR / TEXT column it is the bytes read as a
4002/// latin-1 string (`0x4546` → 'EF'). A PostgreSQL session never produces
4003/// a `Value::Bytes` from these literals, so this only fires under the
4004/// dialect and leaves every other value untouched.
4005pub(crate) fn mysql_bytes_for_column(
4006    v: Value<'static>,
4007    expected: DataType,
4008    mysql: bool,
4009) -> Value<'static> {
4010    if !mysql {
4011        return v;
4012    }
4013    let Value::Bytes(ref b) = v else {
4014        return v;
4015    };
4016    match expected {
4017        DataType::SmallInt
4018        | DataType::Int
4019        | DataType::BigInt
4020        | DataType::Float
4021        | DataType::Real
4022        | DataType::Numeric { .. } => {
4023            let start = b.len().saturating_sub(16);
4024            let acc = b[start..]
4025                .iter()
4026                .fold(0u128, |a, &x| (a << 8) | u128::from(x));
4027            if acc <= i64::MAX as u128 {
4028                #[allow(clippy::cast_possible_truncation)]
4029                Value::BigInt(acc as i64)
4030            } else {
4031                big_literal_to_value(&alloc::format!("{acc}"))
4032            }
4033        }
4034        DataType::Text | DataType::Varchar(_) | DataType::Char(_) => Value::text(
4035            b.iter()
4036                .map(|&x| x as char)
4037                .collect::<alloc::string::String>(),
4038        ),
4039        _ => v,
4040    }
4041}
4042
4043/// v7.39 (round 544) — `timetz → time` and `interval → time`.
4044///
4045/// Measured on PG18:
4046///
4047/// ```text
4048///     '10:20:30.5'::timetz::time      10:20:30.5   (the zone is dropped,
4049///                                                   the wall clock kept)
4050///     '25:00:00'::interval::time      01:00:00     (modulo 24 hours)
4051///     '-1 hour'::interval::time       23:00:00     (and negatives wrap)
4052///     '1 day 02:00:00'::interval::time 02:00:00    (days do not count)
4053/// ```
4054///
4055/// `time → timetz` and `timestamp(tz) → time` are NOT here: the first
4056/// needs the session zone to attach, and the second needs to know which
4057/// of the two timestamp types the source was — `Value::Timestamp` is
4058/// the same variant for both, so answering from the value would be
4059/// right for `timestamp` and off by the session offset for
4060/// `timestamptz`. An error beats a silent wrong answer.
4061fn try_coerce_time_family(
4062    v: &Value<'static>,
4063    expected: DataType,
4064) -> Option<Result<Value<'static>, EngineError>> {
4065    const DAY_US: i64 = 86_400_000_000;
4066    if expected != DataType::Time {
4067        return None;
4068    }
4069    match v {
4070        Value::TimeTz { us, .. } => Some(Ok(Value::Time(*us))),
4071        Value::Interval { micros, .. } => Some(Ok(Value::Time(micros.rem_euclid(DAY_US)))),
4072        _ => None,
4073    }
4074}
4075
4076/// Normalise a value into PG's `oid` domain, or `Ok(None)` when the value is
4077/// not something an oid can be made from.
4078///
4079/// v7.39 (round 667) — extracted rather than copied. The rules lived inline
4080/// in the `::oid` cast and were already right (a negative wraps the way C's
4081/// `(Oid)` cast does, past `u32::MAX` is "OID out of range", bad text is
4082/// PG's 22P02 wording). Assigning INTO an oid column needed the same rules,
4083/// and round 665 had just finished paying for four hand-copies of one
4084/// accumulator, so this is one function with two callers instead.
4085pub(crate) fn coerce_to_oid(v: &Value<'_>) -> Result<Option<Value<'static>>, EvalError> {
4086    let as_i64 = match v {
4087        Value::Null => return Ok(Some(Value::Null)),
4088        Value::SmallInt(n) => i64::from(*n),
4089        Value::Int(n) => i64::from(*n),
4090        Value::BigInt(n) => *n,
4091        Value::Text(t) => match t.trim().parse::<i64>() {
4092            Ok(n) => n,
4093            Err(_) => {
4094                return Err(EvalError::TypeMismatch {
4095                    detail: alloc::format!("invalid input syntax for type oid: {:?}", t.trim()),
4096                });
4097            }
4098        },
4099        _ => return Ok(None),
4100    };
4101    // 32-bit wrap for negatives (C cast semantics).
4102    if (-(1i64 << 31)..0).contains(&as_i64) {
4103        return Ok(Some(Value::BigInt(as_i64 + (1i64 << 32))));
4104    }
4105    if !(0..=i64::from(u32::MAX)).contains(&as_i64) {
4106        return Err(EvalError::TypeMismatch {
4107            detail: "OID out of range".into(),
4108        });
4109    }
4110    Ok(Some(Value::BigInt(as_i64)))
4111}
4112
4113pub(crate) fn coerce_value(
4114    v: Value<'static>,
4115    expected: DataType,
4116    col_name: &str,
4117    position: usize,
4118) -> Result<Value<'static>, EngineError> {
4119    if v.is_null() {
4120        return Ok(Value::Null);
4121    }
4122    // v7.39 (read01 round 113) — a jsonb value cast to a scalar numeric/bool
4123    // target decodes its underlying JSON scalar first (PG's jsonb → int/bigint/
4124    // smallint/numeric/real/float8/bool casts). Json → Json still takes the
4125    // identity fast-path below; this only fires for the scalar targets.
4126    if let Value::Json(ref s) = v {
4127        if let Some(res) = try_coerce_json_scalar(s, expected, col_name, position) {
4128            return res;
4129        }
4130    }
4131    // v7.39 (round 544) — the temporal conversions PG performs and SPG
4132    // refused outright. Found by comparing a probe of SPG's own cast
4133    // function against PG18's pg_cast; see synth_pg_cast's note.
4134    if let Some(res) = try_coerce_time_family(&v, expected) {
4135        return res;
4136    }
4137    // v7.39 (read01 round 54) — `data_type()` is None for the eval-only
4138    // variants that carry no DataType (RegClass, Composite): they are NOT
4139    // NULL, so the old `.expect("non-null")` PANICKED on them. A regclass
4140    // reaching a coercion (e.g. `EXISTS (SELECT 1 WHERE oid_col = 't'::regclass)`,
4141    // which coerces the subquery's row) crashed the query with an
4142    // "internal error" instead of comparing by oid. Fall through to the
4143    // coercion table, which handles the shapes it knows and errors cleanly
4144    // on the rest.
4145    // v7.39 (round 254) — a NUMERIC special (NaN / ±Infinity) crossing a
4146    // cast: every arm below rebuilds its result from `scaled`/`scale`
4147    // with `kind: Finite`, which silently turned a special into 0
4148    // (`'Infinity'::numeric::float8` = 0). PG's table, probed live:
4149    // float8 / real pass the special through; the integer targets refuse
4150    // it; an unconstrained numeric keeps it, and a typmod'd numeric takes
4151    // NaN but overflows on an infinity.
4152    if let Value::Numeric { kind, .. } = v
4153        && kind != spg_storage::NumericKind::Finite
4154    {
4155        use spg_storage::NumericKind as K;
4156        let as_f64 = match kind {
4157            K::NaN => f64::NAN,
4158            K::PosInf => f64::INFINITY,
4159            K::NegInf => f64::NEG_INFINITY,
4160            K::Finite => unreachable!("checked above"),
4161        };
4162        // PG names any infinity "infinity" here, sign included.
4163        let what = if kind == K::NaN { "NaN" } else { "infinity" };
4164        let int_err = |target: &str| {
4165            Err(EngineError::Eval(EvalError::TypeMismatch {
4166                detail: alloc::format!("cannot convert {what} to {target}"),
4167            }))
4168        };
4169        match expected {
4170            DataType::Float => return Ok(Value::Float(as_f64)),
4171            #[allow(clippy::cast_possible_truncation)]
4172            DataType::Real => return Ok(Value::Real(as_f64 as f32)),
4173            DataType::Int => return int_err("integer"),
4174            DataType::BigInt => return int_err("bigint"),
4175            DataType::SmallInt => return int_err("smallint"),
4176            DataType::Numeric { precision, scale } => {
4177                // Unconstrained numeric (the 0/0 sentinel) keeps the
4178                // special; a declared precision overflows on an infinity
4179                // but still accepts NaN (PG: NaN has no magnitude).
4180                if precision != 0 && kind != K::NaN {
4181                    return Err(EngineError::Eval(EvalError::TypeMismatch {
4182                        detail: alloc::string::String::from("numeric field overflow"),
4183                    }));
4184                }
4185                let _ = scale;
4186                return Ok(v);
4187            }
4188            _ => {}
4189        }
4190    }
4191    // v7.39 (round 254) — the reverse direction: an IEEE special arriving
4192    // from float8 / real becomes the NUMERIC special (PG accepts it since
4193    // 14); the finite path below cannot represent one.
4194    if let DataType::Numeric { precision, .. } = expected {
4195        let f = match v {
4196            Value::Float(f) if !f.is_finite() => Some(f),
4197            #[allow(clippy::cast_lossless)]
4198            Value::Real(f) if !f.is_finite() => Some(f as f64),
4199            _ => None,
4200        };
4201        if let Some(f) = f {
4202            use spg_storage::NumericKind as K;
4203            if f.is_nan() {
4204                return Ok(Value::numeric_special(K::NaN));
4205            }
4206            if precision != 0 {
4207                return Err(EngineError::Eval(EvalError::TypeMismatch {
4208                    detail: alloc::string::String::from("numeric field overflow"),
4209                }));
4210            }
4211            return Ok(Value::numeric_special(if f > 0.0 {
4212                K::PosInf
4213            } else {
4214                K::NegInf
4215            }));
4216        }
4217    }
4218    let Some(actual) = v.data_type() else {
4219        return coerce_untyped_value(v, expected, col_name, position);
4220    };
4221    if actual == expected {
4222        return Ok(v);
4223    }
4224    // v7.38.8 — text reaching a json/jsonb column is validated here, the
4225    // way PG validates at its own input boundary, and reports what PG
4226    // reports when it will not parse.
4227    //
4228    // It was not validated at all, and the comment where the coercion
4229    // used to live said so outright: "no structural validation — the
4230    // responsibility for valid JSON lies with the producer". The jsonb
4231    // side went further and swallowed the parse error, storing the raw
4232    // text when canonicalisation failed. So `INSERT INTO t VALUES
4233    // ('{bad')` into a jsonb column was accepted where PG18 answers
4234    // `invalid input syntax for type json`, and every later read of
4235    // that row raised instead — including, in v7.38.7, one on the
4236    // checkpoint thread, which is the worst place for it: writes keep
4237    // being acknowledged while nothing reaches disk.
4238    //
4239    // Handled ahead of the match so the message names the real problem.
4240    // Reported through the generic path it read `expected Jsonb, actual
4241    // Text`, which describes a coercion that is ordinarily fine and
4242    // says nothing about the document being malformed.
4243    //
4244    // This boundary is also what the accessors now rest on: with it
4245    // enforced, a `Value::Json` is valid by construction, and `->>`
4246    // stops parsing the whole document once per row to find that out.
4247    if matches!(expected, DataType::Json | DataType::Jsonb)
4248        && let Value::Text(ref s) | Value::Json(ref s) = v
4249    {
4250        let bad = || {
4251            EngineError::Eval(crate::eval::EvalError::TypeMismatch {
4252                detail: alloc::string::String::from("invalid input syntax for type json"),
4253            })
4254        };
4255        return if expected == DataType::Jsonb {
4256            crate::json::canonicalize_jsonb(s.as_ref())
4257                .map(Value::json)
4258                .map_err(|_| bad())
4259        } else {
4260            crate::json::parse(s.as_ref())
4261                .map_err(|_| bad())
4262                .map(|_| Value::json(s.clone()))
4263        };
4264    }
4265    let coerced: Option<Value<'static>> = match (v, expected) {
4266        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
4267        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
4268        // v7.39 (read01 int.c) — a narrowing overflow is PG's typed
4269        // "smallint out of range" (22003), not a generic type mismatch.
4270        (Value::Int(n), DataType::SmallInt) => match i16::try_from(n) {
4271            Ok(v) => Some(Value::SmallInt(v)),
4272            Err(_) => {
4273                return Err(EngineError::Eval(EvalError::TypeMismatch {
4274                    detail: "smallint out of range".into(),
4275                }));
4276            }
4277        },
4278        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
4279            i128::from(n),
4280            precision,
4281            scale,
4282            col_name,
4283        )?),
4284        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
4285        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
4286        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
4287        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
4288            i128::from(n),
4289            precision,
4290            scale,
4291            col_name,
4292        )?),
4293        (Value::BigInt(n), DataType::Int) => match i32::try_from(n) {
4294            Ok(v) => Some(Value::Int(v)),
4295            Err(_) => {
4296                return Err(EngineError::Eval(EvalError::TypeMismatch {
4297                    detail: "integer out of range".into(),
4298                }));
4299            }
4300        },
4301        (Value::BigInt(n), DataType::SmallInt) => match i16::try_from(n) {
4302            Ok(v) => Some(Value::SmallInt(v)),
4303            Err(_) => {
4304                return Err(EngineError::Eval(EvalError::TypeMismatch {
4305                    detail: "smallint out of range".into(),
4306                }));
4307            }
4308        },
4309        #[allow(clippy::cast_precision_loss)]
4310        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
4311        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
4312            i128::from(n),
4313            precision,
4314            scale,
4315            col_name,
4316        )?),
4317        (Value::Float(x), DataType::Numeric { precision, scale }) => {
4318            // Unconstrained `numeric` (precision 0 is the sentinel —
4319            // numeric(0,0) is invalid in PG) keeps the value's
4320            // natural scale instead of truncating to 0 decimals.
4321            // Route the float through its shortest round-trip decimal
4322            // text so `3.14::numeric` stays 3.14, not 3.
4323            if precision == 0 && scale == 0 && x.is_finite() {
4324                if let Some((mantissa, src_scale)) = parse_numeric_text(&alloc::format!("{x}")) {
4325                    Some(Value::Numeric {
4326                        scaled: mantissa,
4327                        scale: src_scale,
4328                        kind: spg_storage::NumericKind::Finite,
4329                    })
4330                } else {
4331                    Some(numeric_from_float(x, precision, scale, col_name)?)
4332                }
4333            } else {
4334                Some(numeric_from_float(x, precision, scale, col_name)?)
4335            }
4336        }
4337        // v7.39 (read01 round 110) — REAL (float4) → NUMERIC. Mirrors the
4338        // Float arm above; `real::numeric` used to have no arm at all, so the
4339        // value stayed a REAL and the column check rejected it. Format the f32
4340        // via its OWN shortest round-trip decimal (not through f64) so
4341        // `0.1::real::numeric` matches PG's float4 text.
4342        (Value::Real(x), DataType::Numeric { precision, scale }) => {
4343            if precision == 0 && scale == 0 && x.is_finite() {
4344                // v7.39 (round 662) — SIX significant digits, PG's `FLT_DIG`.
4345                // `format!("{x}")` is Rust's shortest round-trip, up to nine
4346                // digits for f32 — right for `real::text`, wrong here.
4347                // `real::numeric` is a different rule and PG measurably takes
4348                // the shorter one: `12345.678::real::numeric` is `12345.7`,
4349                // `1.23456789::real::numeric` is `1.23457`,
4350                // `123456789::real::numeric` is `123457000`. SPG answered
4351                // `12345.678`, `1.2345679`, `123456790` — more digits than a
4352                // float4 carries, presented as if it did.
4353                //
4354                // Found while adding `to_char(real, …)`: PG routes that
4355                // through numeric, not float8, so the missing overload was the
4356                // symptom and this cast was the cause.
4357                let six = alloc::format!("{:.5e}", x);
4358                let six: f64 = six.parse().unwrap_or_else(|_| f64::from(x));
4359                if let Some((mantissa, src_scale)) = parse_numeric_text(&alloc::format!("{six}")) {
4360                    Some(Value::Numeric {
4361                        scaled: mantissa,
4362                        scale: src_scale,
4363                        kind: spg_storage::NumericKind::Finite,
4364                    })
4365                } else {
4366                    Some(numeric_from_float(
4367                        f64::from(x),
4368                        precision,
4369                        scale,
4370                        col_name,
4371                    )?)
4372                }
4373            } else {
4374                Some(numeric_from_float(
4375                    f64::from(x),
4376                    precision,
4377                    scale,
4378                    col_name,
4379                )?)
4380            }
4381        }
4382        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
4383        // canonical decimal text (`"-1234.56"` / `"42"` /
4384        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
4385        // to the column's declared scale. Required for prepared
4386        // binds: `value_to_literal` flattens a Value::Numeric
4387        // into a TEXT literal because Literal carries no native
4388        // Numeric variant, so the placeholder substitution path
4389        // reaches coerce_value as Text → Numeric. Without this
4390        // arm the round-trip surfaces a TypeMismatch even though
4391        // the cell already left the engine as a valid Numeric.
4392        (Value::Text(s), DataType::Numeric { precision, scale }) => {
4393            // v7.38 (read01, T6) — PG's NUMERIC specials (`'NaN'`, `'Infinity'`,
4394            // `'-Infinity'`) parse before the ordinary decimal path.
4395            if let Some(kind) = crate::numeric::parse_numeric_special(&s) {
4396                return Ok(Value::numeric_special(kind));
4397            }
4398            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
4399                // v7.39 (read01 numeric.c) — PG's numeric input accepts
4400                // scientific notation ('1e300'::numeric): expand the exponent
4401                // and re-enter this arm with the plain form (which no longer
4402                // contains an 'e', so this recurses at most once).
4403                match spg_sql::parser::expand_scientific_literal(&s) {
4404                    spg_sql::parser::SciExpanded::Expanded(plain) => {
4405                        return coerce_value(
4406                            Value::Text(plain.into()),
4407                            DataType::Numeric { precision, scale },
4408                            col_name,
4409                            position,
4410                        );
4411                    }
4412                    spg_sql::parser::SciExpanded::Overflow => {
4413                        return Err(EngineError::Eval(EvalError::TypeMismatch {
4414                            detail: "value overflows numeric format".into(),
4415                        }));
4416                    }
4417                    spg_sql::parser::SciExpanded::NotScientific => {}
4418                }
4419                // A plain decimal whose mantissa overflows i128 is still a
4420                // valid unconstrained NUMERIC — keep it exact as NumericBig.
4421                if precision == 0 && scale == 0 {
4422                    if let Some(b) = spg_storage::bignum::BigNumeric::from_decimal_str(&s) {
4423                        return Ok(Value::NumericBig(alloc::boxed::Box::new(b)));
4424                    }
4425                }
4426                return Err(EngineError::Eval(EvalError::TypeMismatch {
4427                    detail: alloc::format!("invalid input syntax for type numeric: \"{s}\""),
4428                }));
4429            };
4430            // Unconstrained `numeric` keeps the parsed scale as-is.
4431            if precision == 0 && scale == 0 {
4432                Some(Value::Numeric {
4433                    scaled: mantissa,
4434                    scale: src_scale,
4435                    kind: spg_storage::NumericKind::Finite,
4436                })
4437            } else {
4438                Some(numeric_rescale(
4439                    mantissa, src_scale, precision, scale, col_name,
4440                )?)
4441            }
4442        }
4443        // Text → DATE / TIMESTAMP: parse canonical text forms.
4444        (Value::Text(s), DataType::Date) => {
4445            // PG truncates a full timestamp string on the way into a
4446            // DATE column (verified vs live PG18.4: INSERT
4447            // '2020-01-01 12:00:00' into a date column stores
4448            // 2020-01-01). Try the plain date parser first, then fall
4449            // back to the timestamp parser (validates the time) floored
4450            // to the day — mirroring the ::date cast path.
4451            let d = eval::parse_date_literal(&s)
4452                .or_else(|| {
4453                    eval::parse_timestamp_literal(&s)
4454                        .and_then(|t| i32::try_from(t.div_euclid(86_400_000_000)).ok())
4455                })
4456                .ok_or_else(|| datetime_parse_error("date", &s))?;
4457            Some(Value::Date(d))
4458        }
4459        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
4460        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
4461        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
4462        // form to the column's numeric / bool type at DEFAULT-
4463        // installation time so the storage check sees a typed
4464        // value. Parse failures fall through to TypeMismatch.
4465        // PG trims surrounding whitespace on numeric text input, so
4466        // `'  256  '::int2` / `'  3.14  '::float8` (both of which route
4467        // through this generic coerce path, unlike `::int` / `::float`
4468        // that trim in the CAST helper) parse rather than error.
4469        // v7.39 (read01 round 90) — a text value that fails to parse as the
4470        // target numeric type is PG's 22P02 `invalid input syntax for type
4471        // <T>: "<value>"`, not SPG's generic "type mismatch in column …". The
4472        // Numeric arm above already worded it this way; these matched it now.
4473        (Value::Text(s), DataType::SmallInt) => Some(Value::SmallInt(
4474            parse_pg_int(&s)
4475                .and_then(|n| i16::try_from(n).ok())
4476                .ok_or_else(|| invalid_input_syntax("smallint", &s))?,
4477        )),
4478        (Value::Text(s), DataType::Int) => Some(Value::Int(
4479            parse_pg_int(&s)
4480                .and_then(|n| i32::try_from(n).ok())
4481                .ok_or_else(|| invalid_input_syntax("integer", &s))?,
4482        )),
4483        (Value::Text(s), DataType::BigInt) => Some(Value::BigInt(
4484            parse_pg_int(&s).ok_or_else(|| invalid_input_syntax("bigint", &s))?,
4485        )),
4486        // v7.39 (round 640) — `INSERT INTO t(x) VALUES ('11')` into an
4487        // `xid` column, which is how PG takes one: the literal is
4488        // unknown-typed and the column's input function reads it. An
4489        // INTEGER in the same place is refused by both engines — PG
4490        // has no int-to-xid cast at all, measured.
4491        (Value::Text(s), DataType::Xid) => Some(Value::Xid(
4492            s.parse::<u32>()
4493                .map_err(|_| invalid_input_syntax("xid", &s))?,
4494        )),
4495        (Value::Xid(x), DataType::Xid) => Some(Value::Xid(x)),
4496        (Value::Text(s), DataType::Xid8) => Some(Value::BigInt(
4497            parse_pg_int(&s).ok_or_else(|| invalid_input_syntax("xid8", &s))?,
4498        )),
4499        // `'16'::xid8` evaluates to a BigInt — xid8 has a declared-type
4500        // identity but no value of its own, the way `xid` has
4501        // `Value::Xid`. The consequence is that SPG accepts a bigint
4502        // where PG refuses one ("column is of type xid8 but expression
4503        // is of type bigint"); closing that needs a `Value::Xid8`, which
4504        // is its own unit of work.
4505        (Value::BigInt(n), DataType::Xid8) => Some(Value::BigInt(n)),
4506        // v7.39 (round 667) — assigning into an OID column. PG takes an
4507        // integer here (and, measured, refuses the same integer for an xid
4508        // column); the range and wrap rules are the cast's, shared.
4509        (ref other, DataType::Oid) => coerce_to_oid(other)?,
4510        (Value::Text(s), DataType::Float) => {
4511            // v7.39 (round 270) — a numeric-looking text outside the
4512            // double range is "out of range", not "invalid input
4513            // syntax"; PG quotes the source either way.
4514            Some(Value::Float(
4515                parse_float8(&s).ok_or_else(|| float_text_error(&s, "double precision"))?,
4516            ))
4517        }
4518        // v7.38 (read01, T-float4) — coerce to REAL narrows to f32.
4519        (Value::Int(n), DataType::Real) => Some(Value::Real(n as f32)),
4520        (Value::SmallInt(n), DataType::Real) => Some(Value::Real(f32::from(n))),
4521        (Value::BigInt(n), DataType::Real) => Some(Value::Real(n as f32)),
4522        (Value::Float(x), DataType::Real) => {
4523            // v7.39 (round 269) — narrowing a finite f64 past the f32
4524            // range overflows; PG words this one "value out of range:
4525            // overflow" (it has no source text to quote).
4526            let narrowed = x as f32;
4527            if narrowed.is_infinite() && x.is_finite() {
4528                return Err(EngineError::Eval(EvalError::TypeMismatch {
4529                    detail: "value out of range: overflow".into(),
4530                }));
4531            }
4532            // v7.39 (round 270) — PG names the other end separately.
4533            if narrowed == 0.0 && x != 0.0 {
4534                return Err(EngineError::Eval(EvalError::TypeMismatch {
4535                    detail: "value out of range: underflow".into(),
4536                }));
4537            }
4538            Some(Value::Real(narrowed))
4539        }
4540        (
4541            Value::Numeric {
4542                scaled,
4543                scale,
4544                kind,
4545            },
4546            DataType::Real,
4547        ) => Some(Value::Real(match kind {
4548            spg_storage::NumericKind::NaN => f32::NAN,
4549            spg_storage::NumericKind::PosInf => f32::INFINITY,
4550            spg_storage::NumericKind::NegInf => f32::NEG_INFINITY,
4551            spg_storage::NumericKind::Finite => {
4552                let mut div = 1.0f64;
4553                for _ in 0..scale {
4554                    div *= 10.0;
4555                }
4556                let x = (scaled as f64 / div) as f32;
4557                // v7.39 (round 270) — same underflow rule at real's
4558                // (much nearer) bottom end.
4559                if x == 0.0 && scaled != 0 {
4560                    return Err(real_out_of_range(&crate::eval::format_numeric(
4561                        scaled, scale,
4562                    )));
4563                }
4564                x
4565            }
4566        })),
4567        (Value::Real(x), DataType::Float) => Some(Value::Float(f64::from(x))),
4568        // v7.39 (round 269) — overflowing the f32 range is an ERROR, not
4569        // an infinity. `parse::<f32>()` reports "1e40" as inf and this
4570        // used to hand that back, so a value PG rejects arrived as
4571        // Infinity and every later comparison against it was wrong. An
4572        // explicitly written infinity still passes; the test is whether
4573        // the SOURCE said infinity, not whether the result is one.
4574        (Value::Text(s), DataType::Real) => {
4575            let t = s.trim();
4576            let x = t
4577                .parse::<f32>()
4578                .ok()
4579                .ok_or_else(|| invalid_input_syntax("real", &s))?;
4580            if x.is_infinite() && !text_is_explicit_infinity(t) {
4581                return Err(real_out_of_range(t));
4582            }
4583            // v7.39 (round 270) — the other end: a nonzero source that
4584            // underflows to zero is an error too, not a silent 0.
4585            if x == 0.0 && float_text_is_nonzero(t) {
4586                return Err(real_out_of_range(t));
4587            }
4588            Some(Value::Real(x))
4589        }
4590        // PG boolin accepts any unambiguous prefix of true/false/yes/no,
4591        // plus on/off/1/0, case-insensitively with surrounding whitespace
4592        // trimmed. `o` alone is ambiguous (on vs off) → error.
4593        (Value::Text(s), DataType::Bool) => match s.trim().to_ascii_lowercase().as_str() {
4594            "0" | "f" | "fa" | "fal" | "fals" | "false" | "n" | "no" | "of" | "off" => {
4595                Some(Value::Bool(false))
4596            }
4597            "1" | "t" | "tr" | "tru" | "true" | "y" | "ye" | "yes" | "on" => {
4598                Some(Value::Bool(true))
4599            }
4600            _ => return Err(invalid_input_syntax("boolean", &s)),
4601        },
4602        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
4603        // classifies as DataType::Bool) is the storage shape every
4604        // mysqldump-restored boolean column lands in. mysqldump emits
4605        // the values as integer `0` / `1` literals, so int → bool
4606        // coerce on INSERT is required for a 0-change cutover. MySQL's
4607        // rule is "any non-zero is truthy"; we follow that for all
4608        // signed int widths so the same coerce path serves an
4609        // explicit `BOOLEAN` column too.
4610        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
4611        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
4612        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
4613        // v7.38.8 — text reaching a json/jsonb column is validated, the
4614        // way PG validates at its own input boundary.
4615        //
4616        // It was not, and the comment here said so: "no structural
4617        // validation — the responsibility for valid JSON lies with the
4618        // producer". The jsonb arm went further and swallowed the parse
4619        // error, storing the raw text when canonicalisation failed. So
4620        // `INSERT INTO t VALUES ('{bad')` into a jsonb column was
4621        // accepted (PG18: `invalid input syntax for type json`), and
4622        // every later read of that row raised instead — including, in
4623        // v7.38.7, one on the checkpoint thread, which is the worst
4624        // place for it because writes keep being acknowledged while
4625        // nothing reaches disk.
4626        //
4627        // Rejecting here is also what lets the accessors trust a
4628        // `Value::Json`: with the column boundary enforced, a value
4629        // that came out of storage IS valid, and `->>` no longer has
4630        // to parse the whole document per row to find that out.
4631        // Text → json/jsonb is handled before this match, so that a
4632        // document that will not parse reports PG's own message
4633        // instead of a type mismatch that misnames the problem.
4634        (Value::Json(s), DataType::Text) => Some(Value::text(s)),
4635        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
4636        // both JSON and JSONB on-disk as `Value::json(String)` —
4637        // they share the underlying text payload. The cast
4638        // `'<text>'::jsonb` produces a Value::Json that needs to
4639        // satisfy a DataType::Jsonb column. Identity coerce in
4640        // both directions so JSON ↔ JSONB assignments work at all
4641        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
4642        (Value::Json(s), DataType::Json) => Some(Value::json(s)),
4643        (Value::Json(s), DataType::Jsonb) => Some(Value::json(
4644            crate::json::canonicalize_jsonb(s.as_ref()).unwrap_or_else(|_| s.into_owned()),
4645        )),
4646        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
4647        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
4648        //   - Escape: `Hello\\000world`  (backslash + octal triples)
4649        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
4650        // Errors surface as TypeMismatch so the operator gets a
4651        // clear "this literal isn't a bytea literal" hint.
4652        (Value::Text(s), DataType::Bytes) => {
4653            let bytes = decode_bytea_literal(&s)
4654                .map_err(|e| EngineError::Eval(EvalError::TypeMismatch { detail: e }))?;
4655            Some(Value::bytes(bytes))
4656        }
4657        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
4658        // output (lowercase, `\x` prefix). Important when a
4659        // SELECT pulls a bytea cell through a Text column path.
4660        (Value::Bytes(b), DataType::Text) => Some(Value::text(encode_bytea_hex(&b))),
4661        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
4662        // unhyphenated, uppercase, and `{...}`-braced forms; we
4663        // funnel all four through `spg_storage::parse_uuid_str`.
4664        // A malformed literal surfaces as a SQL TypeMismatch
4665        // rather than silently inserting garbage — `0-change
4666        // cutover` requires that an app inserting bad UUID text
4667        // sees the same hard error PG would raise.
4668        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
4669            Some(b) => Some(Value::Uuid(b)),
4670            None => {
4671                return Err(EngineError::Eval(EvalError::TypeMismatch {
4672                    detail: alloc::format!("invalid input syntax for type uuid: {s:?}"),
4673                }));
4674            }
4675        },
4676        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
4677        // Surfaces when a SELECT plucks a uuid cell through a
4678        // Text column path (e.g. INSERT INTO log SELECT id::text
4679        // FROM other_table).
4680        (Value::Uuid(b), DataType::Text) => Some(Value::text(spg_storage::format_uuid(&b))),
4681        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
4682        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
4683        // Out-of-range hour/min/sec is a hard SQL error (no
4684        // silent truncation — same 0-change-cutover discipline
4685        // we apply to UUID).
4686        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
4687            Some(us) => Some(Value::Time(us)),
4688            None => {
4689                // v7.39 (round 764, F31 tranche 3 #81) — PG splits the
4690                // refusals: a time-SHAPED literal with an impossible
4691                // component (`25:00:00`, `10:61:00`) is "date/time
4692                // field value out of range" (22008-family), only junk
4693                // is "invalid input syntax" (PG18-measured).
4694                let time_shaped = {
4695                    let core = s.trim().split('.').next().unwrap_or("");
4696                    !core.is_empty()
4697                        && core.split(':').count() >= 2
4698                        && core
4699                            .split(':')
4700                            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
4701                };
4702                let detail = if time_shaped {
4703                    alloc::format!("date/time field value out of range: {s:?}")
4704                } else {
4705                    alloc::format!("invalid input syntax for type time: {s:?}")
4706                };
4707                return Err(EngineError::Eval(EvalError::TypeMismatch { detail }));
4708            }
4709        },
4710        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
4711        (Value::Time(us), DataType::Text) => Some(Value::text(eval::format_time(us))),
4712        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
4713        // check enforces the MySQL canonical 1901..=2155 + 0
4714        // sentinel; out-of-range is a hard SQL error (no silent
4715        // truncation, mirrors P0-32 / P0-25 discipline).
4716        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
4717        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
4718        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
4719        // Text → YEAR. Accepts the 4-digit decimal form only;
4720        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
4721        // 5.7 and is out of scope for v7.17.0.
4722        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
4723            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
4724            Err(_) => {
4725                return Err(EngineError::Eval(EvalError::TypeMismatch {
4726                    detail: alloc::format!("invalid input syntax for type year: {s:?}"),
4727                }));
4728            }
4729        },
4730        // YEAR → Text 4-digit zero-padded.
4731        (Value::Year(y), DataType::Text) => Some(Value::text(alloc::format!("{y:04}"))),
4732        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ.
4733        // v7.39 (round 761, F31 tranche 2 #59) — an offset-less
4734        // literal is accepted at the session offset, PG18-measured
4735        // (`INSERT '07:08:09'` into a TIMETZ column reads back
4736        // `07:08:09+00` in a UTC session). The old "mandatory signed
4737        // offset" rule refused what PG accepts; offset 0 is the same
4738        // session-zero assumption the time→timetz cast below carries.
4739        // v7.39 (round 634) — a time or a timestamp reaching `::TIMETZ`.
4740        // PG registers time -> timetz as IMPLICIT and timestamptz -> timetz
4741        // as an assignment cast; SPG answered "cannot cast time without
4742        // time zone to USER-DEFINED", the target having fallen through to
4743        // the user-type lookup. The session offset is zero here, which is
4744        // what SPG's timetz values already carry.
4745        (Value::Time(t), DataType::TimeTz) => Some(Value::TimeTz {
4746            us: t,
4747            offset_secs: 0,
4748        }),
4749        (Value::Timestamp(t), DataType::TimeTz) => Some(Value::TimeTz {
4750            us: t.rem_euclid(86_400_000_000),
4751            offset_secs: 0,
4752        }),
4753        (Value::Text(s), DataType::TimeTz) => {
4754            match parse_timetz_str(&s).or_else(|| parse_time_str(s.trim()).map(|us| (us, 0))) {
4755                Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
4756                None => {
4757                    return Err(EngineError::Eval(EvalError::TypeMismatch {
4758                        detail: alloc::format!(
4759                            "invalid input syntax for type time with time zone: \
4760                         {s:?}"
4761                        ),
4762                    }));
4763                }
4764            }
4765        }
4766        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
4767        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
4768            Some(Value::text(eval::format_timetz(us, offset_secs)))
4769        }
4770        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
4771        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
4772        // arrive via the Int/BigInt/Float/Numeric arms below.
4773        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
4774            Some(c) => Some(Value::Money(c)),
4775            None => {
4776                return Err(EngineError::Eval(EvalError::TypeMismatch {
4777                    detail: alloc::format!("invalid input syntax for type money: {s:?}"),
4778                }));
4779            }
4780        },
4781        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
4782        // Bare numeric literal is interpreted as a major-unit
4783        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
4784        (Value::SmallInt(n), DataType::Money) => {
4785            Some(Value::Money(i64::from(n).saturating_mul(100)))
4786        }
4787        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
4788        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
4789        (Value::Float(x), DataType::Money) => {
4790            // Round half-away-from-zero to cents (no_std — no
4791            // `f64::round`, so hand-roll via biased truncation).
4792            let scaled = x * 100.0;
4793            let cents = if scaled >= 0.0 {
4794                (scaled + 0.5) as i64
4795            } else {
4796                (scaled - 0.5) as i64
4797            };
4798            Some(Value::Money(cents))
4799        }
4800        (Value::Numeric { scaled, scale, .. }, DataType::Money) => {
4801            // Convert exact decimal to cents (scale 2). If scale > 2,
4802            // round half-away-from-zero. If scale < 2, multiply up.
4803            let cents = if scale == 2 {
4804                scaled
4805            } else if scale < 2 {
4806                let mult = 10_i128.pow(u32::from(2 - scale));
4807                scaled.saturating_mul(mult)
4808            } else {
4809                let div = 10_i128.pow(u32::from(scale - 2));
4810                let half = div / 2;
4811                let bias = if scaled >= 0 { half } else { -half };
4812                (scaled + bias) / div
4813            };
4814            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
4815        }
4816        // MONEY → Text canonical `$N,NNN.CC`.
4817        (Value::Money(c), DataType::Text) => Some(Value::text(eval::format_money(c))),
4818        // MONEY → NUMERIC: integer cents become a scale-2 decimal (dollars).
4819        (Value::Money(c), DataType::Numeric { .. }) => Some(Value::Numeric {
4820            scaled: i128::from(c),
4821            scale: 2,
4822            kind: spg_storage::NumericKind::Finite,
4823        }),
4824        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
4825        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
4826        // `'(a,b)'`, with empty lower or upper for unbounded.
4827        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
4828            Ok(v) => Some(v),
4829            // v7.39 (read01 rangetypes.c) — PG's two distinct rejections.
4830            Err(RangeParseError::Misordered) => {
4831                return Err(EngineError::Eval(EvalError::TypeMismatch {
4832                    detail: alloc::string::String::from(
4833                        "range lower bound must be less than or equal to range upper bound",
4834                    ),
4835                }));
4836            }
4837            Err(RangeParseError::Malformed) => {
4838                return Err(EngineError::Eval(EvalError::TypeMismatch {
4839                    detail: alloc::format!("malformed range literal: \"{s}\""),
4840                }));
4841            }
4842            Err(RangeParseError::BadElement(bad)) => {
4843                return Err(EngineError::Eval(EvalError::TypeMismatch {
4844                    detail: alloc::format!(
4845                        "invalid input syntax for type {}: \"{bad}\"",
4846                        range_element_type_name(kind)
4847                    ),
4848                }));
4849            }
4850        },
4851        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
4852        (v @ Value::Range { .. }, DataType::Text) => Some(Value::text(format_range_str(&v))),
4853        // v7.37.5 ζ-A — Text → network / bit / xml / "char" / money[].
4854        (Value::Text(s), DataType::Inet) => match parse_inet_text(&s) {
4855            Some((family, bits, addr)) => Some(Value::Inet { family, bits, addr }),
4856            None => {
4857                // v7.39 (round 262) — PG's wording: the lowercase type
4858                // name and no column suffix (the cidr arm below already
4859                // had it right).
4860                return Err(EngineError::Eval(EvalError::TypeMismatch {
4861                    detail: alloc::format!("invalid input syntax for type inet: {s:?}"),
4862                }));
4863            }
4864        },
4865        // v7.39 (round 262) — the inet <-> cidr casts, probed live:
4866        // `inet::cidr` keeps the mask length (defaulting to the family's
4867        // full width) and ZEROES the host bits, so `192.168.1.5/24`
4868        // becomes `192.168.1.0/24`; `cidr::inet` passes through
4869        // unchanged. Neither existed, so both raised a storage type
4870        // mismatch on perfectly ordinary SQL.
4871        (Value::Inet { family, bits, addr }, DataType::Cidr) => {
4872            let full = if family == 6 { 128 } else { 32 };
4873            let bits = if bits > full { full } else { bits };
4874            let mut masked = addr;
4875            for i in 0..16usize {
4876                let bit_start = i * 8;
4877                if bit_start >= usize::from(bits) {
4878                    masked[i] = 0;
4879                } else if bit_start + 8 > usize::from(bits) {
4880                    let keep = usize::from(bits) - bit_start;
4881                    masked[i] &= 0xffu8 << (8 - keep);
4882                }
4883            }
4884            Some(Value::Cidr {
4885                family,
4886                bits,
4887                addr: masked,
4888            })
4889        }
4890        (Value::Cidr { family, bits, addr }, DataType::Inet) => {
4891            Some(Value::Inet { family, bits, addr })
4892        }
4893        (Value::Text(s), DataType::Cidr) => match parse_cidr_text(&s) {
4894            Ok(Some((family, bits, addr))) => Some(Value::Cidr { family, bits, addr }),
4895            Err(()) => {
4896                return Err(EngineError::Eval(EvalError::TypeMismatch {
4897                    detail: alloc::format!(
4898                        "invalid cidr value: {s:?} DETAIL: Value has bits set to right of mask."
4899                    ),
4900                }));
4901            }
4902            Ok(None) => {
4903                return Err(EngineError::Eval(EvalError::TypeMismatch {
4904                    detail: alloc::format!("invalid input syntax for type cidr: {s:?}"),
4905                }));
4906            }
4907        },
4908        // INSERT / assignment of a text literal into an INTERVAL column
4909        // parses it, matching the `::interval` cast (mirrors macaddr/inet).
4910        (Value::Text(s), DataType::Interval) => match spg_sql::parser::parse_interval_text(&s) {
4911            Some((months, days, micros)) => Some(Value::Interval {
4912                months,
4913                days,
4914                micros,
4915                kind: spg_storage::IntervalKind::from_fields(months, days, micros),
4916            }),
4917            None => {
4918                return Err(EngineError::Eval(EvalError::TypeMismatch {
4919                    detail: alloc::format!("invalid input syntax for type interval: {s:?}"),
4920                }));
4921            }
4922        },
4923        (Value::Text(s), DataType::Macaddr) => match parse_macaddr_text(&s) {
4924            Some(m) => Some(Value::Macaddr(m)),
4925            None => {
4926                return Err(EngineError::Eval(EvalError::TypeMismatch {
4927                    detail: alloc::format!("invalid input syntax for type macaddr: {s:?}"),
4928                }));
4929            }
4930        },
4931        // v7.39 (read01 pg_lsn.c) — `XX/XX` hex pair, each half <= u32.
4932        (Value::Text(s), DataType::PgLsn) => match parse_pg_lsn_text(&s) {
4933            Some(l) => Some(Value::PgLsn(l)),
4934            None => {
4935                return Err(EngineError::Eval(EvalError::TypeMismatch {
4936                    detail: alloc::format!("invalid input syntax for type pg_lsn: \"{s}\""),
4937                }));
4938            }
4939        },
4940        (Value::Text(s), DataType::Macaddr8) => match parse_macaddr8_text(&s) {
4941            Some(m) => Some(Value::Macaddr8(m)),
4942            None => {
4943                return Err(EngineError::Eval(EvalError::TypeMismatch {
4944                    detail: alloc::format!("invalid input syntax for type macaddr8: {s:?}"),
4945                }));
4946            }
4947        },
4948        // v7.37.5 ship triage — `Value::BitString` self-reports as
4949        // `DataType::BitVarying(0)` (see `Value::data_type`), so an
4950        // INSERT into a `BIT` column triggered a spurious type
4951        // mismatch. Accept BitString into either.
4952        //
4953        // v7.39 (round 281) — and enforce the declared length, which
4954        // used to be parsed and dropped so `bit(3)` took a five-bit
4955        // string. PG's two types differ: BIT is FIXED (a shorter value
4956        // is an error too) while BIT VARYING is a maximum. An explicit
4957        // CAST still pads or truncates — the same assignment-enforces /
4958        // cast-adjusts split the varchar arms below already model.
4959        (Value::BitString { nbits, bytes }, DataType::Bit(n)) => {
4960            // A bare `bit` is `bit(1)` in PG.
4961            let want = if n == 0 { 1 } else { n };
4962            if nbits != want {
4963                return Err(EngineError::Unsupported(alloc::format!(
4964                    "bit string length {nbits} does not match type bit({want})"
4965                )));
4966            }
4967            Some(Value::BitString { nbits, bytes })
4968        }
4969        (Value::BitString { nbits, bytes }, DataType::BitVarying(n)) => {
4970            if n != 0 && nbits > n {
4971                return Err(EngineError::Unsupported(alloc::format!(
4972                    "bit string too long for type bit varying({n})"
4973                )));
4974            }
4975            Some(Value::BitString { nbits, bytes })
4976        }
4977        (Value::Text(s), bit_ty @ (DataType::Bit(_) | DataType::BitVarying(_))) => {
4978            match parse_bit_string_text(&s) {
4979                Some((nbits, bytes)) => {
4980                    // v7.39 (round 325, V57) — the DECLARED width applies to a
4981                    // string literal too. It was checked only on the
4982                    // `B'…'` bit-literal path, so `INSERT INTO t(b)
4983                    // VALUES ('10')` into a `BIT(3)` column was accepted and
4984                    // stored two bits wide — a column that promises a fixed
4985                    // width silently holding another one. PG 18.4:
4986                    // `bit string length 2 does not match type bit(3)`, and
4987                    // `bit string too long for type bit varying(3)` past a
4988                    // varying cap.
4989                    match bit_ty {
4990                        // A bare `bit` is `bit(1)` in PG, as the arm above.
4991                        DataType::Bit(n) => {
4992                            let want = if n == 0 { 1 } else { n };
4993                            if nbits != want {
4994                                return Err(EngineError::Unsupported(alloc::format!(
4995                                    "bit string length {nbits} does not match type bit({want})"
4996                                )));
4997                            }
4998                        }
4999                        DataType::BitVarying(n) if n != 0 && nbits > n => {
5000                            return Err(EngineError::Unsupported(alloc::format!(
5001                                "bit string too long for type bit varying({n})"
5002                            )));
5003                        }
5004                        _ => {}
5005                    }
5006                    Some(Value::bit_string(nbits, bytes))
5007                }
5008                None => {
5009                    // v7.39 (read01 varbit.c) — PG names the first bad digit.
5010                    let bad = s.chars().find(|c| *c != '0' && *c != '1');
5011                    return Err(EngineError::Eval(EvalError::TypeMismatch {
5012                        detail: match bad {
5013                            Some(c) => {
5014                                alloc::format!("\"{c}\" is not a valid binary digit")
5015                            }
5016                            None => alloc::format!("invalid input syntax for BIT: {s:?}"),
5017                        },
5018                    }));
5019                }
5020            }
5021        }
5022        (Value::Text(s), DataType::Xml) => {
5023            // v7.38 (read01 P6.38) — `::xml` (PG's CONTENT mode) requires the
5024            // text to be well-formed: element tags must be balanced and
5025            // properly nested. Plain text, multiple top-level elements,
5026            // comments/PIs/CDATA and self-closing tags are all fine.
5027            if !xml_content_is_well_formed(&s) {
5028                return Err(EngineError::Eval(EvalError::TypeMismatch {
5029                    detail: alloc::format!("invalid XML content: {s:?}"),
5030                }));
5031            }
5032            Some(Value::xml(s))
5033        }
5034        // v7.39 (round 634) — the bpchar forms of two casts the Text arms
5035        // above already have. `'ab'::CHAR(4)::"char"` answered "cannot cast
5036        // character to \"char\"" and `::XML` likewise, while the same value
5037        // as TEXT worked: the cast path never normalises a bpchar the way
5038        // the function dispatch does. PG answers `a` and `ab` — the text
5039        // form of a bpchar drops its padding.
5040        (Value::BpChar(s), DataType::Char1) => {
5041            Some(Value::Char1(s.as_bytes().first().copied().unwrap_or(0)))
5042        }
5043        (Value::BpChar(s), DataType::Xml) => {
5044            let stripped = s.trim_end_matches(' ');
5045            if !xml_content_is_well_formed(stripped) {
5046                return Err(EngineError::Eval(EvalError::TypeMismatch {
5047                    detail: alloc::format!("invalid XML content: {stripped:?}"),
5048                }));
5049            }
5050            Some(Value::xml(alloc::string::String::from(stripped)))
5051        }
5052        // v7.39 (round 634) — bytea to an integer reads the bytes
5053        // BIG-ENDIAN, all of them, and errors when the result does not fit.
5054        // Measured on PG: `'\x3132'` is 12594, a single `'\x31'` is 49, an
5055        // empty bytea is 0, and three bytes into a smallint is
5056        // "smallint out of range".
5057        (Value::Bytes(b), DataType::SmallInt | DataType::Int | DataType::BigInt) => {
5058            let mut acc: i128 = 0;
5059            for byte in b.iter() {
5060                acc = acc.saturating_mul(256).saturating_add(i128::from(*byte));
5061            }
5062            let (fits, made) = match expected {
5063                DataType::SmallInt => (
5064                    i16::try_from(acc).is_ok(),
5065                    i16::try_from(acc).map(Value::SmallInt).ok(),
5066                ),
5067                DataType::Int => (
5068                    i32::try_from(acc).is_ok(),
5069                    i32::try_from(acc).map(Value::Int).ok(),
5070                ),
5071                _ => (
5072                    i64::try_from(acc).is_ok(),
5073                    i64::try_from(acc).map(Value::BigInt).ok(),
5074                ),
5075            };
5076            if !fits {
5077                return Err(EngineError::Eval(EvalError::TypeMismatch {
5078                    detail: alloc::format!("{} out of range", pg_type_name_for_error(expected)),
5079                }));
5080            }
5081            made
5082        }
5083        // v7.39 (read01 char.c) — an integer coerces to "char" by its
5084        // low byte (65::"char" = 'A'; PG's i2char/int4char).
5085        (Value::Int(n), DataType::Char1) => Some(Value::Char1((n & 0xff) as u8)),
5086        (Value::SmallInt(n), DataType::Char1) => Some(Value::Char1((n & 0xff) as u8)),
5087        (Value::BigInt(n), DataType::Char1) => Some(Value::Char1((n & 0xff) as u8)),
5088        (Value::Text(s), DataType::Char1) => {
5089            // v7.39 (read01 utils/adt, char.c) — charin accepts the
5090            // `\ooo` octal form charout produces for high bytes
5091            // ('\101'::"char" = 'A'); otherwise the FIRST byte, with
5092            // any remainder silently discarded (PG's compatibility
5093            // provision); empty = 0x00.
5094            let bytes = s.as_bytes();
5095            if bytes.len() == 4
5096                && bytes[0] == b'\\'
5097                && bytes[1..].iter().all(|b| (b'0'..=b'7').contains(b))
5098            {
5099                let v = ((bytes[1] - b'0') << 6) | ((bytes[2] - b'0') << 3) | (bytes[3] - b'0');
5100                Some(Value::Char1(v))
5101            } else {
5102                let b = s.bytes().next().unwrap_or(0);
5103                Some(Value::Char1(b))
5104            }
5105        }
5106        // v7.37.5 ζ-A — inverse coerces.
5107        (Value::Inet { family, bits, addr }, DataType::Text) => {
5108            // v7.39 (read01 inet family) — PG's text(inet) ALWAYS carries
5109            // the /netmask (192.168.1.5 -> "192.168.1.5/32"), unlike the
5110            // display form which suppresses a full-length mask.
5111            let base = format_inet(family, bits, &addr);
5112            Some(Value::text(if base.contains('/') {
5113                base
5114            } else {
5115                alloc::format!("{base}/{bits}")
5116            }))
5117        }
5118        (Value::Cidr { family, bits, addr }, DataType::Text) => {
5119            Some(Value::text(format_inet(family, bits, &addr)))
5120        }
5121        (Value::Macaddr(m), DataType::Text) => Some(Value::text(format_macaddr(&m))),
5122        (Value::Macaddr8(m), DataType::Text) => Some(Value::text(format_macaddr8(&m))),
5123        (Value::PgLsn(l), DataType::Text) => Some(Value::text(format_pg_lsn(l))),
5124        // MACADDR → MACADDR8: PG widens EUI-48 to EUI-64 by inserting the
5125        // `ff:fe` marker in the middle (08:00:2b:01:02:03 → 08:00:2b:ff:fe:01:02:03).
5126        (Value::Macaddr(m), DataType::Macaddr8) => Some(Value::Macaddr8([
5127            m[0], m[1], m[2], 0xff, 0xfe, m[3], m[4], m[5],
5128        ])),
5129        (Value::BitString { nbits, bytes }, DataType::Text) => {
5130            Some(Value::text(format_bit_string(nbits, &bytes)))
5131        }
5132        // BIT → integer: MSB-first bit value (PG bit→int cast).
5133        #[allow(clippy::cast_possible_truncation)]
5134        (Value::BitString { nbits, bytes }, DataType::SmallInt) => {
5135            Some(Value::SmallInt(bit_string_to_i64(nbits, &bytes) as i16))
5136        }
5137        #[allow(clippy::cast_possible_truncation)]
5138        (Value::BitString { nbits, bytes }, DataType::Int) => {
5139            Some(Value::Int(bit_string_to_i64(nbits, &bytes) as i32))
5140        }
5141        (Value::BitString { nbits, bytes }, DataType::BigInt) => {
5142            Some(Value::BigInt(bit_string_to_i64(nbits, &bytes)))
5143        }
5144        (Value::Xml(s), DataType::Text) => Some(Value::text(s)),
5145        (Value::Char1(b), DataType::Text) => Some(Value::text((b as char).to_string())),
5146        // v7.37.5 ε — Text → geometry coerce. Each parser returns
5147        // None on malformed input; we surface a TypeMismatch with
5148        // the column name so the engine error is debuggable.
5149        (Value::Text(s), DataType::Point) => match parse_point(&s) {
5150            Some(p) => Some(Value::Point(p)),
5151            None => {
5152                return Err(EngineError::Eval(EvalError::TypeMismatch {
5153                    detail: alloc::format!("invalid input syntax for type point: {s:?}"),
5154                }));
5155            }
5156        },
5157        (Value::Text(s), DataType::Lseg) => match parse_lseg_text(&s) {
5158            Some((p1, p2)) => Some(Value::Lseg(p1, p2)),
5159            None => {
5160                return Err(EngineError::Eval(EvalError::TypeMismatch {
5161                    detail: alloc::format!("invalid input syntax for type lseg: {s:?}"),
5162                }));
5163            }
5164        },
5165        (Value::Text(s), DataType::PgBox) => match parse_box_text(&s) {
5166            Some((ur, ll)) => Some(Value::PgBox(ur, ll)),
5167            None => {
5168                return Err(EngineError::Eval(EvalError::TypeMismatch {
5169                    detail: alloc::format!("invalid input syntax for type box: {s:?}"),
5170                }));
5171            }
5172        },
5173        (Value::Text(s), DataType::Line) => match parse_line_text(&s) {
5174            Some((a, b, c)) => Some(Value::Line { a, b, c }),
5175            None => {
5176                // v7.39 (round 775, F31 J6) — the degenerate `{0,0,C}`
5177                // form gets PG's OWN sentence (measured), not the
5178                // generic syntax one.
5179                let zero_ab = s
5180                    .trim()
5181                    .strip_prefix('{')
5182                    .and_then(|x| x.strip_suffix('}'))
5183                    .map(|inner| inner.split(',').collect::<alloc::vec::Vec<_>>())
5184                    .is_some_and(|parts| {
5185                        parts.len() == 3
5186                            && parts[0].trim().parse::<f64>() == Ok(0.0)
5187                            && parts[1].trim().parse::<f64>() == Ok(0.0)
5188                            && parts[2].trim().parse::<f64>().is_ok()
5189                    });
5190                let detail = if zero_ab {
5191                    alloc::string::String::from(
5192                        "invalid line specification: A and B cannot both be zero",
5193                    )
5194                } else {
5195                    alloc::format!("invalid input syntax for type line: {s:?}")
5196                };
5197                return Err(EngineError::Eval(EvalError::TypeMismatch { detail }));
5198            }
5199        },
5200        (Value::Text(s), DataType::Circle) => match parse_circle_text(&s) {
5201            Some((center, radius)) => Some(Value::Circle { center, radius }),
5202            None => {
5203                return Err(EngineError::Eval(EvalError::TypeMismatch {
5204                    detail: alloc::format!("invalid input syntax for type circle: {s:?}"),
5205                }));
5206            }
5207        },
5208        (Value::Text(s), DataType::Path) => match parse_path_text(&s) {
5209            Some((points, closed)) => Some(Value::Path { points, closed }),
5210            None => {
5211                return Err(EngineError::Eval(EvalError::TypeMismatch {
5212                    detail: alloc::format!("invalid input syntax for type path: {s:?}"),
5213                }));
5214            }
5215        },
5216        // v7.39 (read01 geo_ops.c) — box_poly: a box converts to its
5217        // 4-corner polygon (low, (low.x, high.y), high, (high.x, low.y)).
5218        (Value::PgBox(a, b), DataType::Polygon) => {
5219            let (hx, hy) = (a.x.max(b.x), a.y.max(b.y));
5220            let (lx, ly) = (a.x.min(b.x), a.y.min(b.y));
5221            let p = |x: f64, y: f64| spg_storage::Point2D { x, y };
5222            Some(Value::Polygon(alloc::vec![
5223                p(lx, ly),
5224                p(lx, hy),
5225                p(hx, hy),
5226                p(hx, ly),
5227            ]))
5228        }
5229        (Value::Text(s), DataType::Polygon) => match parse_polygon_text(&s) {
5230            Some(points) => Some(Value::Polygon(points)),
5231            None => {
5232                return Err(EngineError::Eval(EvalError::TypeMismatch {
5233                    detail: alloc::format!("invalid input syntax for type polygon: {s:?}"),
5234                }));
5235            }
5236        },
5237        // v7.37.5 ε — geometry → Text canonical forms.
5238        (Value::Point(p), DataType::Text) => Some(Value::text(format_point(p))),
5239        (Value::Lseg(p1, p2), DataType::Text) => Some(Value::text(format_lseg(p1, p2))),
5240        (Value::PgBox(ur, ll), DataType::Text) => Some(Value::text(format_pg_box(ur, ll))),
5241        (Value::Line { a, b, c }, DataType::Text) => Some(Value::text(format_line(a, b, c))),
5242        (Value::Circle { center, radius }, DataType::Text) => {
5243            Some(Value::text(format_circle(center, radius)))
5244        }
5245        (Value::Path { points, closed }, DataType::Text) => {
5246            Some(Value::text(format_path(&points, closed)))
5247        }
5248        (Value::Polygon(points), DataType::Text) => Some(Value::text(format_polygon(&points))),
5249        // v7.37.5 δ — Text → Multirange. Accepts `{}` empty and
5250        // `{[a,b),[c,d),...}` comma-separated ranges; each
5251        // subrange parses with the parent kind.
5252        // v7.39 (round 256) — `range::<type>multirange`: PG casts a range
5253        // to the one-element multirange containing it (an empty range
5254        // gives the empty multirange).
5255        (ref rv @ Value::Range { kind: rk, .. }, DataType::Multirange(kind)) => {
5256            if rk != kind {
5257                return Err(EngineError::Eval(EvalError::TypeMismatch {
5258                    detail: alloc::format!(
5259                        "cannot cast type {} to {}",
5260                        DataType::Range(rk),
5261                        DataType::Multirange(kind)
5262                    ),
5263                }));
5264            }
5265            crate::eval::binop::range_as_multirange(rv)
5266        }
5267        (Value::Text(s), DataType::Multirange(kind)) => match parse_multirange_str(&s, kind) {
5268            // v7.39 (round 231) — a multirange is normalized whatever built
5269            // it. The constructor function already sorted / merged / dropped
5270            // empties; the text cast kept the literal's spans verbatim, so
5271            // `'{[1,3),[3,5)}'::int4multirange` printed back two adjacent
5272            // spans where PG prints the merged `{[1,5)}`.
5273            Some(ranges) => Some(Value::Multirange {
5274                kind,
5275                ranges: crate::eval::binop::normalize_multirange_spans(kind, &ranges),
5276            }),
5277            None => {
5278                return Err(EngineError::Eval(EvalError::TypeMismatch {
5279                    detail: alloc::format!("invalid input syntax for multirange type: {s:?}"),
5280                }));
5281            }
5282        },
5283        // Multirange → Text canonical form (`{[a,b),[c,d)}`).
5284        (Value::Multirange { ranges, .. }, DataType::Text) => {
5285            Some(Value::text(format_multirange(&ranges)))
5286        }
5287        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
5288        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
5289            Some(pairs) => Some(Value::Hstore(pairs)),
5290            None => {
5291                return Err(EngineError::Eval(EvalError::TypeMismatch {
5292                    detail: alloc::format!("invalid input syntax for type hstore: {s:?}"),
5293                }));
5294            }
5295        },
5296        // Hstore → Text canonical `"k"=>"v"` form.
5297        (Value::Hstore(pairs), DataType::Text) => Some(Value::text(format_hstore_str(&pairs))),
5298        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
5299        // external `'{{a,b},{c,d}}'` literal.
5300        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
5301            Ok(m) => Some(Value::IntArray2D(m)),
5302            Err(e) => {
5303                return Err(EngineError::Eval(EvalError::TypeMismatch {
5304                    detail: alloc::format!("invalid input syntax for INT[][]: {s:?}: {e}"),
5305                }));
5306            }
5307        },
5308        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
5309            Ok(m) => Some(Value::BigIntArray2D(m)),
5310            Err(e) => {
5311                return Err(EngineError::Eval(EvalError::TypeMismatch {
5312                    detail: alloc::format!("invalid input syntax for BIGINT[][]: {s:?}: {e}"),
5313                }));
5314            }
5315        },
5316        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
5317            Ok(m) => Some(Value::TextArray2D(m)),
5318            Err(e) => {
5319                return Err(EngineError::Eval(EvalError::TypeMismatch {
5320                    detail: alloc::format!("invalid input syntax for TEXT[][]: {s:?}: {e}"),
5321                }));
5322            }
5323        },
5324        // 2D arrays → Text canonical nested form.
5325        (Value::IntArray2D(rows), DataType::Text) => Some(Value::text(format_int_2d_text(&rows))),
5326        (Value::BigIntArray2D(rows), DataType::Text) => {
5327            Some(Value::text(format_bigint_2d_text(&rows)))
5328        }
5329        (Value::TextArray2D(rows), DataType::Text) => Some(Value::text(format_text_2d_text(&rows))),
5330        // v7.10.11 — Text → TEXT[]. Decode PG's external array
5331        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
5332        // is the literal `NULL`; everything else is a quoted or
5333        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
5334        (Value::Text(s), DataType::TextArray) => {
5335            // v7.39 (round 325, V57) — PG's wording (and the same message
5336            // the CAST path gives for the identical input; this one used to
5337            // name TEXT[] whatever the column's element type was).
5338            let arr = decode_text_array_literal(&s).map_err(|_| {
5339                EngineError::Eval(EvalError::TypeMismatch {
5340                    detail: malformed_array_literal(&s),
5341                })
5342            })?;
5343            Some(Value::TextArray(arr))
5344        }
5345        // v7.16.0 — Text → IntArray / BigIntArray for the
5346        // spg-sqlx Bind path. Decode the PG external form
5347        // `{1,2,3}` as a TEXT array first, then parse each
5348        // element as int. Same shape as the TextArray decode
5349        // above with an element-wise narrow.
5350        (Value::Text(s), DataType::IntArray) => {
5351            // v7.39 (round 325, V57) — PG's wording (and the same message
5352            // the CAST path gives for the identical input; this one used to
5353            // name TEXT[] whatever the column's element type was).
5354            let arr = decode_text_array_literal(&s).map_err(|_| {
5355                EngineError::Eval(EvalError::TypeMismatch {
5356                    detail: malformed_array_literal(&s),
5357                })
5358            })?;
5359            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
5360            for elem in arr {
5361                match elem {
5362                    None => out.push(None),
5363                    Some(t) => {
5364                        let n: i32 = t.parse().map_err(|_| {
5365                            EngineError::Eval(EvalError::TypeMismatch {
5366                                detail: alloc::format!(
5367                                    "invalid input syntax for type integer: {t:?}"
5368                                ),
5369                            })
5370                        })?;
5371                        out.push(Some(n));
5372                    }
5373                }
5374            }
5375            Some(Value::IntArray(out))
5376        }
5377        // v7.38 (read01) — the remaining Text → typed-array casts
5378        // (`'{1.5}'::numeric[]`, `'{t}'::bool[]`, `'{2020-01-01}'::date[]`, …),
5379        // which previously errored while `::int[]` / `::text[]` worked.
5380        (Value::Text(s), DataType::SmallIntArray) => Some(Value::SmallIntArray(
5381            decode_array_elems(&s, DataType::SmallInt, col_name, position)?
5382                .into_iter()
5383                .map(|o| match o {
5384                    Some(Value::SmallInt(n)) => Some(n),
5385                    _ => None,
5386                })
5387                .collect(),
5388        )),
5389        (Value::Text(s), DataType::BoolArray) => {
5390            // v7.39 (read01 round 92) — a 2-D bool literal `{{t,f},{f,t}}`
5391            // becomes a BoolArray2D (the ::int[]/::text[] cast path learned this
5392            // separately; the typed-array coerce path routes here). 1-D stays a
5393            // BoolArray.
5394            if let Some(rows) = crate::eval::values::split_2d_rows(&s) {
5395                let mut row_vals: Vec<Value<'static>> = Vec::with_capacity(rows.len());
5396                for r in &rows {
5397                    let bools: Vec<Option<bool>> =
5398                        decode_array_elems(r, DataType::Bool, col_name, position)?
5399                            .into_iter()
5400                            .map(|o| match o {
5401                                Some(Value::Bool(b)) => Some(b),
5402                                _ => None,
5403                            })
5404                            .collect();
5405                    row_vals.push(Value::BoolArray(bools));
5406                }
5407                return crate::eval::values::build_2d_from_rows(&row_vals).ok_or_else(|| {
5408                    EngineError::Eval(EvalError::TypeMismatch {
5409                        detail: malformed_array_literal(&s),
5410                    })
5411                });
5412            }
5413            Some(Value::BoolArray(
5414                decode_array_elems(&s, DataType::Bool, col_name, position)?
5415                    .into_iter()
5416                    .map(|o| match o {
5417                        Some(Value::Bool(b)) => Some(b),
5418                        _ => None,
5419                    })
5420                    .collect(),
5421            ))
5422        }
5423        (Value::Text(s), DataType::FloatArray) => Some(Value::FloatArray(
5424            decode_array_elems(&s, DataType::Float, col_name, position)?
5425                .into_iter()
5426                .map(|o| match o {
5427                    Some(Value::Float(f)) => Some(f),
5428                    _ => None,
5429                })
5430                .collect(),
5431        )),
5432        (Value::Text(s), DataType::NumericArray) => Some(Value::NumericArray(
5433            decode_array_elems(
5434                &s,
5435                DataType::Numeric {
5436                    precision: 0,
5437                    scale: 0,
5438                },
5439                col_name,
5440                position,
5441            )?
5442            .into_iter()
5443            .map(|o| match o {
5444                Some(Value::Numeric { scaled, scale, .. }) => Some((scaled, scale)),
5445                _ => None,
5446            })
5447            .collect(),
5448        )),
5449        (Value::Text(s), DataType::DateArray) => Some(Value::DateArray(
5450            decode_array_elems(&s, DataType::Date, col_name, position)?
5451                .into_iter()
5452                .map(|o| match o {
5453                    Some(Value::Date(d)) => Some(d),
5454                    _ => None,
5455                })
5456                .collect(),
5457        )),
5458        (Value::Text(s), DataType::UuidArray) => Some(Value::UuidArray(
5459            decode_array_elems(&s, DataType::Uuid, col_name, position)?
5460                .into_iter()
5461                .map(|o| match o {
5462                    Some(Value::Uuid(u)) => Some(u),
5463                    _ => None,
5464                })
5465                .collect(),
5466        )),
5467        // v7.39 (round 694) — `oid[]` decodes exactly as `bigint[]` does;
5468        // the variant exists to keep the DECLARED type, not to change the
5469        // body. Listed here rather than mapped to BigIntArray upstream
5470        // because mapping it upstream is what made `pg_typeof('{1,2}'::oid[])`
5471        // answer `bigint[]`, which is the defect round 667 closed for the
5472        // scalar.
5473        (Value::Text(s), DataType::BigIntArray | DataType::OidArray) => {
5474            // v7.39 (round 325, V57) — PG's wording (and the same message
5475            // the CAST path gives for the identical input; this one used to
5476            // name TEXT[] whatever the column's element type was).
5477            let arr = decode_text_array_literal(&s).map_err(|_| {
5478                EngineError::Eval(EvalError::TypeMismatch {
5479                    detail: malformed_array_literal(&s),
5480                })
5481            })?;
5482            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
5483            for elem in arr {
5484                match elem {
5485                    None => out.push(None),
5486                    Some(t) => {
5487                        let n: i64 = t.parse().map_err(|_| {
5488                            EngineError::Eval(EvalError::TypeMismatch {
5489                                detail: alloc::format!(
5490                                    "invalid input syntax for type bigint: {t:?}"
5491                                ),
5492                            })
5493                        })?;
5494                        out.push(Some(n));
5495                    }
5496                }
5497            }
5498            Some(Value::BigIntArray(out))
5499        }
5500        // v7.10.11 — TEXT[] → Text round-trip uses PG's
5501        // external array form (`{a,b,NULL}`). Lets a SELECT
5502        // pull an array column through any Text-side codepath.
5503        (Value::TextArray(items), DataType::Text) => Some(Value::text(encode_text_array(&items))),
5504        // v7.37.5 ship triage — empty `ARRAY[]` literal lands as
5505        // `Value::TextArray(vec![])`. Allow widening to the typed
5506        // array sibling so `ARRAY[]::BOOL[]` / `::FLOAT[]` etc.
5507        // round-trip through INSERT into the typed column. Only
5508        // empty contents go through silently — non-empty TextArray
5509        // must round-trip via per-element parsing(handled by the
5510        // existing element-specific coercion paths above).
5511        (Value::TextArray(items), DataType::BoolArray) if items.is_empty() => {
5512            Some(Value::BoolArray(alloc::vec::Vec::new()))
5513        }
5514        (Value::TextArray(items), DataType::SmallIntArray) if items.is_empty() => {
5515            Some(Value::SmallIntArray(alloc::vec::Vec::new()))
5516        }
5517        (Value::TextArray(items), DataType::IntArray) if items.is_empty() => {
5518            Some(Value::IntArray(alloc::vec::Vec::new()))
5519        }
5520        (Value::TextArray(items), DataType::BigIntArray) if items.is_empty() => {
5521            Some(Value::BigIntArray(alloc::vec::Vec::new()))
5522        }
5523        (Value::TextArray(items), DataType::FloatArray) if items.is_empty() => {
5524            Some(Value::FloatArray(alloc::vec::Vec::new()))
5525        }
5526        // `expr::float8[]` — an array literal reaches here as TEXT[] (elements
5527        // rendered to text); parse each element to f64. NULLs pass through.
5528        (Value::TextArray(items), DataType::FloatArray) => {
5529            let mut out = alloc::vec::Vec::with_capacity(items.len());
5530            let mut ok = true;
5531            for item in items {
5532                match item {
5533                    None => out.push(None),
5534                    Some(s) => match s.trim().parse::<f64>() {
5535                        Ok(x) => out.push(Some(x)),
5536                        Err(_) => {
5537                            ok = false;
5538                            break;
5539                        }
5540                    },
5541                }
5542            }
5543            if ok {
5544                Some(Value::FloatArray(out))
5545            } else {
5546                None
5547            }
5548        }
5549        // Identity for an already-float array, and widen integer arrays
5550        // element-wise (PG accepts `ARRAY[1,2]::float8[]`).
5551        (Value::FloatArray(items), DataType::FloatArray) => Some(Value::FloatArray(items)),
5552        #[allow(clippy::cast_precision_loss)]
5553        (Value::IntArray(items), DataType::FloatArray) => Some(Value::FloatArray(
5554            items.into_iter().map(|o| o.map(|n| f64::from(n))).collect(),
5555        )),
5556        #[allow(clippy::cast_precision_loss)]
5557        (Value::BigIntArray(items), DataType::FloatArray) => Some(Value::FloatArray(
5558            items.into_iter().map(|o| o.map(|n| n as f64)).collect(),
5559        )),
5560        // v7.38 (read01) — widen a NUMERIC[] into float8[] element-wise (PG
5561        // accepts `ARRAY[1.5::numeric]::float8[]` and coerces a numeric array
5562        // into a float8[] column on INSERT). Mirrors the scalar Numeric→Float.
5563        #[allow(clippy::cast_precision_loss)]
5564        (Value::NumericArray(items), DataType::FloatArray) => Some(Value::FloatArray(
5565            items
5566                .into_iter()
5567                .map(|o| {
5568                    o.map(|(scaled, scale)| {
5569                        crate::eval::format_numeric(scaled, scale)
5570                            .parse()
5571                            .unwrap_or(f64::NAN)
5572                    })
5573                })
5574                .collect(),
5575        )),
5576        // v7.38 (read01) — the rest of the numeric-array coercion matrix PG
5577        // accepts on INSERT / cast. Widening int→bigint / int·bigint→numeric /
5578        // float→numeric never fails; narrowing bigint→int fails the whole
5579        // coercion (→ None) if any element overflows i32.
5580        (Value::IntArray(items), DataType::BigIntArray) => Some(Value::BigIntArray(
5581            items.into_iter().map(|o| o.map(i64::from)).collect(),
5582        )),
5583        (Value::BigIntArray(items), DataType::IntArray) => {
5584            let mut out = alloc::vec::Vec::with_capacity(items.len());
5585            let mut ok = true;
5586            for o in items {
5587                match o {
5588                    None => out.push(None),
5589                    Some(n) => match i32::try_from(n) {
5590                        Ok(v) => out.push(Some(v)),
5591                        Err(_) => {
5592                            ok = false;
5593                            break;
5594                        }
5595                    },
5596                }
5597            }
5598            if ok { Some(Value::IntArray(out)) } else { None }
5599        }
5600        (Value::IntArray(items), DataType::NumericArray) => Some(Value::NumericArray(
5601            items
5602                .into_iter()
5603                .map(|o| o.map(|n| (i128::from(n), 0_u16)))
5604                .collect(),
5605        )),
5606        (Value::BigIntArray(items), DataType::NumericArray) => Some(Value::NumericArray(
5607            items
5608                .into_iter()
5609                .map(|o| o.map(|n| (i128::from(n), 0_u16)))
5610                .collect(),
5611        )),
5612        (Value::FloatArray(items), DataType::NumericArray) => {
5613            let mut out = alloc::vec::Vec::with_capacity(items.len());
5614            let mut ok = true;
5615            for o in items {
5616                match o {
5617                    None => out.push(None),
5618                    Some(x) => match parse_numeric_text(&alloc::format!("{x}")) {
5619                        Some((mantissa, scale)) => out.push(Some((mantissa, scale))),
5620                        None => {
5621                            ok = false;
5622                            break;
5623                        }
5624                    },
5625                }
5626            }
5627            if ok {
5628                Some(Value::NumericArray(out))
5629            } else {
5630                None
5631            }
5632        }
5633        // v7.38 (read01) — narrow a NUMERIC[] into int[] / bigint[] element-wise,
5634        // rounding half away from zero (PG) like the scalar Numeric→Int coercion.
5635        // An out-of-range element fails the whole coercion (→ None).
5636        (Value::NumericArray(items), DataType::IntArray) => {
5637            let mut out = alloc::vec::Vec::with_capacity(items.len());
5638            let mut ok = true;
5639            for o in items {
5640                match o {
5641                    None => out.push(None),
5642                    Some((scaled, scale)) => {
5643                        match i32::try_from(numeric_round_to_integer(scaled, scale)) {
5644                            Ok(v) => out.push(Some(v)),
5645                            Err(_) => {
5646                                ok = false;
5647                                break;
5648                            }
5649                        }
5650                    }
5651                }
5652            }
5653            if ok { Some(Value::IntArray(out)) } else { None }
5654        }
5655        (Value::NumericArray(items), DataType::BigIntArray) => {
5656            let mut out = alloc::vec::Vec::with_capacity(items.len());
5657            let mut ok = true;
5658            for o in items {
5659                match o {
5660                    None => out.push(None),
5661                    Some((scaled, scale)) => {
5662                        match i64::try_from(numeric_round_to_integer(scaled, scale)) {
5663                            Ok(v) => out.push(Some(v)),
5664                            Err(_) => {
5665                                ok = false;
5666                                break;
5667                            }
5668                        }
5669                    }
5670                }
5671            }
5672            if ok {
5673                Some(Value::BigIntArray(out))
5674            } else {
5675                None
5676            }
5677        }
5678        // v7.38 (read01, T2) — float8[] → int[] / bigint[], rounding each element
5679        // half-to-even (PG's float→int rule, distinct from numeric's half-away).
5680        // A non-finite / out-of-range element fails the whole coercion.
5681        #[allow(clippy::cast_possible_truncation)]
5682        (Value::FloatArray(items), DataType::IntArray) => {
5683            let mut out = alloc::vec::Vec::with_capacity(items.len());
5684            let mut ok = true;
5685            for o in items {
5686                match o {
5687                    None => out.push(None),
5688                    Some(x) if x.is_finite() => {
5689                        let r = crate::eval::math::f64_round_half_even(x);
5690                        if r >= f64::from(i32::MIN) && r <= f64::from(i32::MAX) {
5691                            out.push(Some(r as i32));
5692                        } else {
5693                            ok = false;
5694                            break;
5695                        }
5696                    }
5697                    Some(_) => {
5698                        ok = false;
5699                        break;
5700                    }
5701                }
5702            }
5703            if ok { Some(Value::IntArray(out)) } else { None }
5704        }
5705        #[allow(clippy::cast_possible_truncation)]
5706        (Value::FloatArray(items), DataType::BigIntArray) => {
5707            let mut out = alloc::vec::Vec::with_capacity(items.len());
5708            let mut ok = true;
5709            for o in items {
5710                match o {
5711                    None => out.push(None),
5712                    Some(x) if x.is_finite() => {
5713                        out.push(Some(crate::eval::math::f64_round_half_even(x) as i64));
5714                    }
5715                    Some(_) => {
5716                        ok = false;
5717                        break;
5718                    }
5719                }
5720            }
5721            if ok {
5722                Some(Value::BigIntArray(out))
5723            } else {
5724                None
5725            }
5726        }
5727        (Value::TextArray(items), DataType::NumericArray) if items.is_empty() => {
5728            Some(Value::NumericArray(alloc::vec::Vec::new()))
5729        }
5730        (Value::TextArray(items), DataType::DateArray) if items.is_empty() => {
5731            Some(Value::DateArray(alloc::vec::Vec::new()))
5732        }
5733        (Value::TextArray(items), DataType::TimestampArray) if items.is_empty() => {
5734            Some(Value::TimestampArray(alloc::vec::Vec::new()))
5735        }
5736        (Value::TextArray(items), DataType::TimestamptzArray) if items.is_empty() => {
5737            Some(Value::TimestamptzArray(alloc::vec::Vec::new()))
5738        }
5739        (Value::TextArray(items), DataType::UuidArray) if items.is_empty() => {
5740            Some(Value::UuidArray(alloc::vec::Vec::new()))
5741        }
5742        (Value::TextArray(items), DataType::JsonArray) if items.is_empty() => {
5743            Some(Value::JsonArray(alloc::vec::Vec::new()))
5744        }
5745        (Value::TextArray(items), DataType::JsonbArray) if items.is_empty() => {
5746            Some(Value::JsonbArray(alloc::vec::Vec::new()))
5747        }
5748        (Value::TextArray(items), DataType::BytesArray) if items.is_empty() => {
5749            Some(Value::BytesArray(alloc::vec::Vec::new()))
5750        }
5751        (Value::TextArray(items), DataType::IntervalArray) if items.is_empty() => {
5752            Some(Value::IntervalArray(alloc::vec::Vec::new()))
5753        }
5754        // Non-empty `TEXT[]` → typed array (`ARRAY[..]::bool[]`, `::numeric[]`,
5755        // `::date[]`, `::timestamp[]`, `::uuid[]`): parse each element via the
5756        // scalar path. Empty arrays are handled by the arms above.
5757        (
5758            Value::TextArray(items),
5759            dt @ (DataType::BoolArray
5760            | DataType::NumericArray
5761            | DataType::DateArray
5762            | DataType::TimestampArray
5763            | DataType::TimestamptzArray
5764            | DataType::IntervalArray
5765            | DataType::UuidArray),
5766        ) => coerce_text_array_to(items, dt, col_name)?,
5767        // v7.39 (round 326, V43) — the same targets from a STRING LITERAL.
5768        // `'{1,2}'::int[]` had a Text arm and worked; `'{…}'::timestamp[]`,
5769        // `::timestamptz[]` and `::interval[]` had none, so the literal
5770        // stayed TEXT and the cast died as a plain type mismatch — a whole
5771        // literal form that simply did not exist for the temporal arrays.
5772        (
5773            Value::Text(s),
5774            dt @ (DataType::TimestampArray | DataType::TimestamptzArray | DataType::IntervalArray),
5775        ) => {
5776            let items = decode_text_array_literal(&s).map_err(|_| {
5777                EngineError::Eval(EvalError::TypeMismatch {
5778                    detail: malformed_array_literal(&s),
5779                })
5780            })?;
5781            coerce_text_array_to(items, dt, col_name)?
5782        }
5783        (Value::TextArray(items), DataType::MoneyArray) if items.is_empty() => {
5784            Some(Value::MoneyArray(alloc::vec::Vec::new()))
5785        }
5786        // v7.37.5 ship triage — IntArray(empty) widens to
5787        // SmallIntArray for the `INSERT INTO t (xs) VALUES
5788        // (ARRAY[1::smallint, …])` path where the array literal
5789        // collected mixed int widths into IntArray.
5790        (Value::IntArray(items), DataType::SmallIntArray) => {
5791            let mut out = alloc::vec::Vec::with_capacity(items.len());
5792            let mut ok = true;
5793            for item in items {
5794                match item {
5795                    None => out.push(None),
5796                    Some(n) => match i16::try_from(n) {
5797                        Ok(x) => out.push(Some(x)),
5798                        Err(_) => {
5799                            ok = false;
5800                            break;
5801                        }
5802                    },
5803                }
5804            }
5805            if ok {
5806                Some(Value::SmallIntArray(out))
5807            } else {
5808                None
5809            }
5810        }
5811        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
5812        // Matches the existing Text → TsVector arm and the
5813        // `::vector` cast: PG-canonical pgvector external form
5814        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
5815        // column boundary. Dim mismatch surfaces as TypeMismatch.
5816        // For SQ8 / HALF encodings we chain through the standard
5817        // quantise helpers so the storage shape matches the
5818        // declared encoding without a second coerce pass.
5819        (Value::Text(s), DataType::Vector { dim, encoding }) => {
5820            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
5821                EngineError::Eval(EvalError::TypeMismatch {
5822                    detail: alloc::format!("cannot parse {s:?} as VECTOR"),
5823                })
5824            })?;
5825            if parsed.len() != dim as usize {
5826                return Err(EngineError::Eval(EvalError::TypeMismatch {
5827                    detail: alloc::format!(
5828                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
5829                        parsed.len()
5830                    ),
5831                }));
5832            }
5833            Some(match encoding {
5834                VecEncoding::F32 => Value::vector(parsed),
5835                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
5836                VecEncoding::F16 => {
5837                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
5838                }
5839            })
5840        }
5841        // v7.16.1 — Text → TSVECTOR auto-coerce for the
5842        // INSERT-side wire path (mailrs round-9 A.2.a). PG
5843        // implicitly promotes the TEXT literal at INSERT into a
5844        // TSVECTOR column; SPG previously rejected with a hard
5845        // type mismatch, blocking 23,276 pg_dump rows into
5846        // `messages.search_vector`. We route through the same
5847        // `decode_tsvector_external` the `::tsvector` cast
5848        // already uses, so PG-canonical forms (`'word'`,
5849        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
5850        (Value::Text(s), DataType::TsVector) => {
5851            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
5852                EngineError::Eval(EvalError::TypeMismatch {
5853                    detail: alloc::format!("cannot parse {s:?} as TSVECTOR: {e}"),
5854                })
5855            })?;
5856            Some(Value::TsVector(lexs))
5857        }
5858        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
5859            let t = eval::parse_timestamp_literal(&s)
5860                .ok_or_else(|| datetime_parse_error("timestamp", &s))?;
5861            Some(Value::Timestamp(t))
5862        }
5863        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
5864        // TIMESTAMP → day truncation).
5865        (Value::Date(i32::MAX), DataType::Timestamp | DataType::Timestamptz) => {
5866            Some(Value::Timestamp(i64::MAX))
5867        }
5868        (Value::Date(i32::MIN), DataType::Timestamp | DataType::Timestamptz) => {
5869            Some(Value::Timestamp(i64::MIN))
5870        }
5871        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
5872            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
5873        }
5874        // v7.9.21 — Value::Timestamp lands in either Timestamp
5875        // or Timestamptz columns; the on-disk layout is the
5876        // same i64 microseconds UTC.
5877        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
5878        (Value::Timestamp(t), DataType::Date) => {
5879            let days = t.div_euclid(86_400_000_000);
5880            i32::try_from(days).ok().map(Value::Date)
5881        }
5882        // v7.39 (round 633) — the time of day out of a timestamp.
5883        //
5884        // `TIMESTAMP '2020-01-02 03:04:05'::TIME` answered "cannot cast
5885        // timestamp without time zone to time without time zone"; PG
5886        // answers `03:04:05`, and has the cast registered as an assignment
5887        // one. `rem_euclid` rather than `%` so a pre-epoch timestamp gives
5888        // a time in [0, 24h) instead of a negative one. A timestamptz value
5889        // is carried in the same variant, so it comes through here too.
5890        (Value::Timestamp(t), DataType::Time) => Some(Value::Time(t.rem_euclid(86_400_000_000))),
5891        // v7.39 (read01 numeric.c) — a NumericBig is already an unconstrained
5892        // NUMERIC ('…0.5::numeric' where the mantissa exceeds i128); pass it
5893        // through. A declared numeric(p, s) still falls to the typed error.
5894        (
5895            Value::NumericBig(b),
5896            DataType::Numeric {
5897                precision: 0,
5898                scale: 0,
5899            },
5900        ) => Some(Value::NumericBig(b)),
5901        (
5902            Value::Numeric {
5903                scaled,
5904                scale: src_scale,
5905                ..
5906            },
5907            DataType::Numeric { precision, scale },
5908        ) => {
5909            // v7.38 (read01) — the unconstrained `::numeric` sentinel (0, 0)
5910            // keeps the value's natural scale, matching the Float/Text→Numeric
5911            // arms above; only a declared numeric(p, s) rescales. Without this,
5912            // casting an existing NUMERIC through unconstrained numeric
5913            // (`n::numeric(5,2)::numeric`) rounded it to scale 0.
5914            if precision == 0 && scale == 0 {
5915                Some(Value::Numeric {
5916                    scaled,
5917                    scale: src_scale,
5918                    kind: spg_storage::NumericKind::Finite,
5919                })
5920            } else {
5921                Some(numeric_rescale(
5922                    scaled, src_scale, precision, scale, col_name,
5923                )?)
5924            }
5925        }
5926        // v7.39 (round 272) — an arbitrary-precision value cast to a
5927        // DECLARED numeric had no arm at all, so a 47-digit literal
5928        // going into numeric(50,2) — a column PG accepts — reported an
5929        // internal storage type mismatch.
5930        (Value::NumericBig(b), DataType::Numeric { precision, scale }) => {
5931            if precision == 0 && scale == 0 {
5932                Some(Value::NumericBig(b))
5933            } else {
5934                #[allow(clippy::cast_sign_loss)]
5935                let rounded = if scale < 0 {
5936                    // Round to the multiple of 10^|scale| and land at 0.
5937                    b.round_to(0)
5938                } else {
5939                    b.round_to(scale as u16)
5940                };
5941                let out = crate::eval::binop::bignum_to_value(rounded);
5942                // The declared precision still binds; check it on the
5943                // decimal text, which both forms can produce.
5944                crate::numeric::check_precision_text(&out, precision, scale, col_name)?;
5945                Some(out)
5946            }
5947        }
5948        #[allow(clippy::cast_precision_loss)]
5949        (Value::Numeric { scaled, scale, .. }, DataType::Float) => {
5950            // v7.39 (round 271) — parse the decimal text rather than
5951            // dividing by a power built with repeated multiplication.
5952            // With scale widened to u16 that loop both accumulated
5953            // rounding error (1e-300 came out 9.999999999999999e-301)
5954            // and ran to infinity for a large enough scale, which then
5955            // looked like an underflow.
5956            let text = crate::eval::format_numeric(scaled, scale);
5957            let x: f64 = text.parse().unwrap_or(f64::NAN);
5958            // v7.39 (round 270) — a nonzero NUMERIC that underflows the
5959            // double range is an error in PG, quoting the decimal
5960            // expansion. It used to arrive as a silent zero.
5961            if x == 0.0 && scaled != 0 {
5962                return Err(float_out_of_range(
5963                    &crate::eval::format_numeric(scaled, scale),
5964                    "double precision",
5965                ));
5966            }
5967            Some(Value::Float(x))
5968        }
5969        // v7.39 (read01 numeric.c) — a big NUMERIC (`3.14e100` literal) casts
5970        // to float8 through its decimal text; a value beyond the double range
5971        // errors like PG ("value out of range: overflow").
5972        // v7.39 (round 269) — the same route to real. Without this arm a
5973        // NUMERIC literal past the i128 range (1.8e38 and up) never
5974        // reached a real cast at all and surfaced an internal
5975        // "expected REAL, got NUMERIC(0)" storage mismatch.
5976        (Value::NumericBig(b), DataType::Real) => {
5977            let text = b.to_decimal_str();
5978            let x: f32 = text.parse().map_err(|_| real_out_of_range(&text))?;
5979            if !x.is_finite() || (x == 0.0 && float_text_is_nonzero(&text)) {
5980                return Err(real_out_of_range(&text));
5981            }
5982            Some(Value::Real(x))
5983        }
5984        (Value::NumericBig(b), DataType::Float) => {
5985            // v7.39 (round 270) — PG quotes the decimal expansion here
5986            // rather than saying "value out of range: overflow", which
5987            // it reserves for narrowing a double.
5988            let text = b.to_decimal_str();
5989            let x: f64 = text
5990                .parse()
5991                .map_err(|_| float_out_of_range(&text, "double precision"))?;
5992            if !x.is_finite() || (x == 0.0 && float_text_is_nonzero(&text)) {
5993                return Err(float_out_of_range(&text, "double precision"));
5994            }
5995            Some(Value::Float(x))
5996        }
5997        // v7.38 (read01) — coercing NUMERIC into an integer column rounds half
5998        // away from zero (PG assignment cast: `1.5 → 2`), matching the `::int`
5999        // cast path; it previously truncated (`1.7 → 1`).
6000        // v7.39 (read01 float.c) — float → integer coercion (int4()/int8()/
6001        // int2() function casts, INSERT float into int column): PG rounds
6002        // half-to-even and errors on a non-finite / out-of-range value
6003        // rather than saturating.
6004        (Value::Float(x), DataType::Int) => {
6005            let r = crate::eval::math::f64_round_half_even(x);
6006            if !r.is_finite() || !(-2_147_483_648.0..=2_147_483_647.0).contains(&r) {
6007                return Err(EngineError::Eval(EvalError::TypeMismatch {
6008                    detail: "integer out of range".into(),
6009                }));
6010            }
6011            #[allow(clippy::cast_possible_truncation)]
6012            Some(Value::Int(r as i32))
6013        }
6014        (Value::Float(x), DataType::BigInt) => {
6015            let r = crate::eval::math::f64_round_half_even(x);
6016            if !r.is_finite()
6017                || !(-9.223_372_036_854_776e18..=9.223_372_036_854_776e18).contains(&r)
6018            {
6019                return Err(EngineError::Eval(EvalError::TypeMismatch {
6020                    detail: "bigint out of range".into(),
6021                }));
6022            }
6023            #[allow(clippy::cast_possible_truncation)]
6024            Some(Value::BigInt(r as i64))
6025        }
6026        (Value::Float(x), DataType::SmallInt) => {
6027            let r = crate::eval::math::f64_round_half_even(x);
6028            if !r.is_finite() || !(-32768.0..=32767.0).contains(&r) {
6029                return Err(EngineError::Eval(EvalError::TypeMismatch {
6030                    detail: "smallint out of range".into(),
6031                }));
6032            }
6033            #[allow(clippy::cast_possible_truncation)]
6034            Some(Value::SmallInt(r as i16))
6035        }
6036        // v7.39 (read01 round 112) — REAL (float4) → integer types. Mirrors the
6037        // float8 arms above (round half-to-even, PG's rule); these had no arm at
6038        // all, so `real::int` errored "cannot cast Real to int".
6039        (Value::Real(x), DataType::Int) => {
6040            let r = crate::eval::math::f64_round_half_even(f64::from(x));
6041            if !r.is_finite() || !(-2_147_483_648.0..=2_147_483_647.0).contains(&r) {
6042                return Err(EngineError::Eval(EvalError::TypeMismatch {
6043                    detail: "integer out of range".into(),
6044                }));
6045            }
6046            #[allow(clippy::cast_possible_truncation)]
6047            Some(Value::Int(r as i32))
6048        }
6049        (Value::Real(x), DataType::BigInt) => {
6050            let r = crate::eval::math::f64_round_half_even(f64::from(x));
6051            if !r.is_finite()
6052                || !(-9.223_372_036_854_776e18..=9.223_372_036_854_776e18).contains(&r)
6053            {
6054                return Err(EngineError::Eval(EvalError::TypeMismatch {
6055                    detail: "bigint out of range".into(),
6056                }));
6057            }
6058            #[allow(clippy::cast_possible_truncation)]
6059            Some(Value::BigInt(r as i64))
6060        }
6061        (Value::Real(x), DataType::SmallInt) => {
6062            let r = crate::eval::math::f64_round_half_even(f64::from(x));
6063            if !r.is_finite() || !(-32768.0..=32767.0).contains(&r) {
6064                return Err(EngineError::Eval(EvalError::TypeMismatch {
6065                    detail: "smallint out of range".into(),
6066                }));
6067            }
6068            #[allow(clippy::cast_possible_truncation)]
6069            Some(Value::SmallInt(r as i16))
6070        }
6071        (Value::Numeric { scaled, scale, .. }, DataType::Int) => {
6072            let rounded = numeric_round_to_integer(scaled, scale);
6073            i32::try_from(rounded).ok().map(Value::Int)
6074        }
6075        (Value::Numeric { scaled, scale, .. }, DataType::BigInt) => {
6076            let rounded = numeric_round_to_integer(scaled, scale);
6077            i64::try_from(rounded).ok().map(Value::BigInt)
6078        }
6079        (Value::Numeric { scaled, scale, .. }, DataType::SmallInt) => {
6080            let rounded = numeric_round_to_integer(scaled, scale);
6081            i16::try_from(rounded).ok().map(Value::SmallInt)
6082        }
6083        // VARCHAR(n) enforces an upper bound on character count. A bare
6084        // `varchar` (no typmod) is modelled as `Varchar(0)` and, like PG, holds
6085        // a string of any length — `'a'::varchar` must not read as VARCHAR(0).
6086        // v7.39 (round 291) — `name` is text truncated to NAMEDATALEN-1
6087        // (63) bytes. PG truncates silently rather than erroring, which
6088        // is the behaviour a catalog identifier column needs.
6089        (Value::Text(s), DataType::Name) => {
6090            let mut cut = s.into_owned();
6091            if cut.len() > 63 {
6092                let mut idx = 63;
6093                while !cut.is_char_boundary(idx) {
6094                    idx -= 1;
6095                }
6096                cut.truncate(idx);
6097            }
6098            Some(Value::text(cut))
6099        }
6100        (Value::Text(s), DataType::Varchar(max)) => {
6101            if max == 0 || u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
6102                Some(Value::text(s))
6103            } else {
6104                // v7.39 (bpchar epic) — overflow that is only trailing
6105                // blanks is cut AT the limit (PG keeps 'abcd ' from
6106                // 'abcd  ' in varchar(5) — not a full strip); anything
6107                // else is 22001 with PG's phrasing.
6108                let excess_all_blanks = s.chars().skip(max as usize).all(|c| c == ' ');
6109                if excess_all_blanks {
6110                    Some(Value::text(
6111                        s.chars()
6112                            .take(max as usize)
6113                            .collect::<alloc::string::String>(),
6114                    ))
6115                } else {
6116                    return Err(EngineError::Unsupported(alloc::format!(
6117                        "value too long for type character varying({max})"
6118                    )));
6119                }
6120            }
6121        }
6122        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
6123        // when the column declares `VECTOR(N) USING SQ8` and
6124        // the INSERT VALUES expression yields a raw f32 vector
6125        // (the normal pgvector-shape literal). Dim mismatch
6126        // falls through the `_ => None` arm and surfaces as
6127        // `TypeMismatch` with the expected SQ8 column type —
6128        // matching the F32 path's existing error.
6129        (
6130            Value::Vector(v),
6131            DataType::Vector {
6132                dim,
6133                encoding: VecEncoding::Sq8,
6134            },
6135        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
6136        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
6137        // columns. Bit-exact at the storage layer (modulo
6138        // half-precision rounding); no rerank pass needed at
6139        // search time.
6140        (
6141            Value::Vector(v),
6142            DataType::Vector {
6143                dim,
6144                encoding: VecEncoding::F16,
6145            },
6146        ) if v.len() == dim as usize => Some(Value::HalfVector(
6147            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
6148        )),
6149        // CHAR(n) right-pads with U+0020 to exactly n chars. Overflow that
6150        // is only trailing blanks is trimmed to fit (PG: 'abcd  ' fits
6151        // CHAR(5)); real overflow is 22001.
6152        (Value::Text(s), DataType::Char(size)) => {
6153            // v7.39 (bpchar epic) — bare `bpchar` (no length) is PG's
6154            // unlimited blank-trimmed character type: store stripped,
6155            // no pad, no length check.
6156            if size == 0 {
6157                return Ok(Value::BpChar(alloc::borrow::Cow::Owned(
6158                    s.trim_end_matches(' ').to_string(),
6159                )));
6160            }
6161            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
6162            let body = if len > size {
6163                let trimmed = s.trim_end_matches(' ');
6164                let tlen = u32::try_from(trimmed.chars().count()).unwrap_or(u32::MAX);
6165                if tlen > size {
6166                    return Err(EngineError::Unsupported(alloc::format!(
6167                        "value too long for type character({size})"
6168                    )));
6169                }
6170                trimmed.to_string()
6171            } else {
6172                s.into_owned()
6173            };
6174            let need = (size as usize) - body.chars().count();
6175            let mut padded = body;
6176            padded.reserve(need);
6177            for _ in 0..need {
6178                padded.push(' ');
6179            }
6180            // v7.38 (read01, T11) — CHAR(n) is bpchar: blank-padded, and
6181            // length / comparison / ::text ignore the padding (handled at those
6182            // sites).
6183            Some(Value::BpChar(alloc::borrow::Cow::Owned(padded)))
6184        }
6185        _ => None,
6186    };
6187    coerced.ok_or_else(|| {
6188        EngineError::Storage(StorageError::TypeMismatch {
6189            column: col_name.into(),
6190            expected,
6191            actual,
6192            position,
6193        })
6194    })
6195}
6196
6197/// v7.38 (read01, T3.C3) — a lexer-validated big decimal literal → NumericBig,
6198/// demoted to a plain Numeric if its mantissa happens to fit i128.
6199pub(crate) fn big_literal_to_value(s: &str) -> Value<'static> {
6200    let b = spg_storage::bignum::BigNumeric::from_decimal_str(s).expect("lexer-validated decimal");
6201    match b.to_i128() {
6202        Some(scaled) => Value::Numeric {
6203            scaled,
6204            scale: b.scale(),
6205            kind: spg_storage::NumericKind::Finite,
6206        },
6207        None => Value::NumericBig(alloc::boxed::Box::new(b)),
6208    }
6209}
6210
6211/// v7.39 (round 233 / round 236) — do two types share a PG type category,
6212/// so a set operation, an ARRAY constructor, a VALUES list, CASE, COALESCE
6213/// or GREATEST/LEAST can resolve them to one result type? Same type
6214/// always does; otherwise PG unifies within the numeric, string and
6215/// date/time families and refuses across them (probed against 18.4:
6216/// int ∪ bigint → bigint, text ∪ varchar → text, date ∪ timestamp →
6217/// timestamp, but int ∪ boolean, int ∪ text and text ∪ date are all
6218/// refused).
6219pub(crate) fn types_unify(a: DataType, b: DataType) -> bool {
6220    fn category(t: DataType) -> Option<u8> {
6221        Some(match t {
6222            DataType::SmallInt
6223            | DataType::Int
6224            | DataType::BigInt
6225            | DataType::Numeric { .. }
6226            | DataType::Real
6227            | DataType::Float => 1,
6228            DataType::Text | DataType::Varchar(_) | DataType::Char(_) => 2,
6229            DataType::Date | DataType::Timestamp | DataType::Timestamptz => 3,
6230            _ => return None,
6231        })
6232    }
6233    if a == b {
6234        return true;
6235    }
6236    match (category(a), category(b)) {
6237        (Some(x), Some(y)) => x == y,
6238        // Outside the families a set operation needs the exact same type;
6239        // `a == b` above already covered that.
6240        _ => false,
6241    }
6242}
6243
6244/// v7.39 (round 236) — the type name PG puts in a "types X and Y cannot be
6245/// matched" message. `pg_data_type_text` answers for
6246/// `information_schema.columns.data_type`, where every array is the
6247/// pseudo-name `ARRAY`; an error message names the real thing
6248/// (`integer[]`).
6249/// v7.39 (round 622, S05a) — the `Option<DataType>` form, which is what
6250/// `Value::data_type()` returns and therefore what every "got X" error had.
6251///
6252/// Those errors printed it with `{:?}`, so a user asking for `upper(1)` was
6253/// told the argument was `Some(Int)` — Rust's Debug for an Option wrapping an
6254/// internal enum. 421 sites did this. `None` is the eval-only variants that
6255/// carry no storage type (RegClass, Composite); PG calls an untyped value
6256/// `unknown`, and that is what it becomes here.
6257pub(crate) fn pg_type_name_for_error_opt(t: Option<DataType>) -> alloc::string::String {
6258    match t {
6259        Some(t) => pg_type_name_for_error(t),
6260        None => alloc::string::String::from("unknown"),
6261    }
6262}
6263
6264pub(crate) fn pg_type_name_for_error(t: DataType) -> alloc::string::String {
6265    use spg_storage::DataType as D;
6266    let elem = match t {
6267        D::TextArray => Some(D::Text),
6268        D::IntArray => Some(D::Int),
6269        D::BigIntArray => Some(D::BigInt),
6270        D::SmallIntArray => Some(D::SmallInt),
6271        D::FloatArray => Some(D::Float),
6272        D::NumericArray => Some(D::Numeric {
6273            precision: 0,
6274            scale: 0,
6275        }),
6276        D::BoolArray => Some(D::Bool),
6277        D::DateArray => Some(D::Date),
6278        D::TimestampArray => Some(D::Timestamp),
6279        D::TimestamptzArray => Some(D::Timestamptz),
6280        D::IntervalArray => Some(D::Interval),
6281        D::UuidArray => Some(D::Uuid),
6282        D::JsonArray | D::JsonbArray => Some(D::Jsonb),
6283        D::BytesArray => Some(D::Bytes),
6284        D::MoneyArray => Some(D::Money),
6285        _ => None,
6286    };
6287    match elem {
6288        Some(e) => alloc::format!("{}[]", crate::system_catalog::pg_data_type_text(e)),
6289        None => crate::system_catalog::pg_data_type_text(t),
6290    }
6291}