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/// ```
3060/// v7.39 — what MySQL calls a value that does not fit, in the STRICT
3061/// case where it refuses rather than bends.
3062///
3063/// Same classification as `mysql_fit_warning` and derived from it, so
3064/// the two can never describe the same failure differently — which they
3065/// did: the warning path had learned MySQL's vocabulary and the error
3066/// path still spoke PostgreSQL's, so a MySQL client that let a value
3067/// through got `Out of range value for column 'n' at row 1` and one
3068/// that was refused got `integer out of range` with errno 1690.
3069///
3070/// The codes are NOT simply the warning's. Measured on MySQL 9.7.2,
3071/// `sql_mode='STRICT_TRANS_TABLES'` versus `sql_mode=''`:
3072///
3073/// ```text
3074///                     non-strict (warning)          strict (error)
3075///   TINYINT <- 999    1264 Out of range value…      1264 (22003) same wording
3076///   VARCHAR(3) <- …   1265 Data truncated…          1406 (22001) Data too long…
3077///   INT <- 'abc'      1366 Incorrect integer value  1366 (HY000) same wording
3078///   INT <- '12xy'     1265 Data truncated…          1265 (01000) same wording
3079///   omitted NOT NULL  1364 Field … no default       1364 (HY000) same wording
3080/// ```
3081///
3082/// The string case is the one that breaks the pattern: both the code and
3083/// the wording change. Assuming otherwise would have been wrong on
3084/// exactly the column type this version's worst defect lived in.
3085///
3086/// Returns the errno, the SQLSTATE MySQL pairs with it, and the message.
3087/// `None` when the value fits and there is nothing to refuse.
3088pub(crate) fn mysql_fit_error(
3089    before: &Value<'_>,
3090    after: &Value<'_>,
3091    schema: &ColumnSchema,
3092    row: usize,
3093    omitted: bool,
3094) -> Option<(u16, &'static str, alloc::string::String)> {
3095    let w = mysql_fit_warning(before, after, schema, row, omitted)?;
3096    let col = &schema.name;
3097    Some(match w.code {
3098        // A string that had to be cut is refused as "too long", not as
3099        // "truncated" — different errno AND different wording.
3100        1265 if matches!(
3101            schema.ty,
3102            DataType::Varchar(_) | DataType::Char(_) | DataType::Text
3103        ) =>
3104        {
3105            (
3106                1406,
3107                "22001",
3108                alloc::format!("Data too long for column '{col}' at row {row}"),
3109            )
3110        }
3111        1265 => (1265, "01000", w.message),
3112        1264 => (1264, "22003", w.message),
3113        1366 => (1366, "HY000", w.message),
3114        1364 => (1364, "HY000", w.message),
3115        other => (other, "HY000", w.message),
3116    })
3117}
3118
3119pub(crate) fn mysql_fit_warning(
3120    before: &Value<'_>,
3121    after: &Value<'_>,
3122    schema: &ColumnSchema,
3123    row: usize,
3124    omitted: bool,
3125) -> Option<crate::MysqlWarning> {
3126    if before == after {
3127        return None;
3128    }
3129    let col = &schema.name;
3130    // An omitted NOT NULL column is a different complaint from a value
3131    // that would not fit.
3132    if omitted || before.is_null() {
3133        return Some(crate::MysqlWarning {
3134            level: "Warning",
3135            code: 1364,
3136            message: alloc::format!("Field '{col}' doesn't have a default value"),
3137        });
3138    }
3139    // v7.39 — `Numeric` belongs here. Without it a DECIMAL that would not
3140    // fit fell to the 1265 default and was reported as a truncation, where
3141    // MySQL calls it 1264 `Out of range value`.
3142    let numeric_col = matches!(
3143        schema.ty,
3144        DataType::SmallInt
3145            | DataType::Int
3146            | DataType::BigInt
3147            | DataType::Float
3148            | DataType::Real
3149            | DataType::Numeric { .. }
3150    );
3151    if numeric_col {
3152        // v7.39 — the NOUN is the column's type, and MySQL is not
3153        // consistent about its case. Measured on 9.7.2, one string into
3154        // each:
3155        //
3156        //     BIGINT   Incorrect integer value: 'abc' …
3157        //     DECIMAL  Incorrect decimal value: 'abc' …
3158        //     FLOAT    Incorrect FLOAT value: 'abc' …
3159        //     DOUBLE   Incorrect DOUBLE value: 'abc' …
3160        //
3161        // Two lower-case, two upper-case. A drop-in copies that rather
3162        // than tidying it.
3163        let noun = match schema.ty {
3164            DataType::Numeric { .. } => "decimal",
3165            DataType::Real => "FLOAT",
3166            DataType::Float => "DOUBLE",
3167            _ => "integer",
3168        };
3169        // A string given to a numeric column is 1366; a number that did
3170        // not fit its range is 1264.
3171        return Some(if matches!(before, Value::Text(_) | Value::BpChar(_)) {
3172            crate::MysqlWarning {
3173                level: "Warning",
3174                code: 1366,
3175                message: alloc::format!(
3176                    "Incorrect {noun} value: '{}' for column '{col}' at row {row}",
3177                    crate::eval::value_to_text(before)
3178                ),
3179            }
3180        } else {
3181            crate::MysqlWarning {
3182                level: "Warning",
3183                code: 1264,
3184                message: alloc::format!("Out of range value for column '{col}' at row {row}"),
3185            }
3186        });
3187    }
3188    Some(crate::MysqlWarning {
3189        level: "Warning",
3190        code: 1265,
3191        message: alloc::format!("Data truncated for column '{col}' at row {row}"),
3192    })
3193}
3194
3195/// v7.39 — carry a value that this clamp does not model through
3196/// unchanged, so a shape nobody measured is not silently rewritten.
3197fn numeric_untouched(v: Value<'static>, _schema: &ColumnSchema) -> Value<'static> {
3198    v
3199}
3200
3201/// v7.39 — restate a scaled integer at a different scale, rounding half
3202/// away from zero, which is what MySQL does going to a narrower scale
3203/// (measured: `DECIMAL(3,1) <- 1.26` stores `1.3`, not `1.2`).
3204fn restate_scaled(scaled: i128, from: u16, to: u16) -> i128 {
3205    if from == to {
3206        return scaled;
3207    }
3208    if to > from {
3209        let f = 10i128.checked_pow(u32::from(to - from)).unwrap_or(1);
3210        return scaled.saturating_mul(f);
3211    }
3212    let f = 10i128.checked_pow(u32::from(from - to)).unwrap_or(1);
3213    if f == 0 {
3214        return scaled;
3215    }
3216    let half = f / 2;
3217    if scaled >= 0 {
3218        (scaled + half) / f
3219    } else {
3220        (scaled - half) / f
3221    }
3222}
3223
3224pub(crate) fn mysql_ignore_fit(v: Value<'static>, schema: &ColumnSchema) -> Value<'static> {
3225    if v.is_null() {
3226        if schema.nullable {
3227            return v;
3228        }
3229        // MySQL fills a NOT NULL column with its type's zero value.
3230        return match schema.ty {
3231            DataType::SmallInt | DataType::Int | DataType::BigInt => Value::BigInt(0),
3232            DataType::Float | DataType::Real => Value::Float(0.0),
3233            DataType::Text | DataType::Varchar(_) | DataType::Char(_) => Value::text(""),
3234            _ => v,
3235        };
3236    }
3237    // A string bound for an integer column: MySQL reads the leading numeric
3238    // prefix and calls the rest a truncation warning.
3239    if let Value::Text(ref s) = v
3240        && matches!(
3241            schema.ty,
3242            DataType::SmallInt | DataType::Int | DataType::BigInt
3243        )
3244        && s.trim().parse::<i64>().is_err()
3245    {
3246        return Value::BigInt(leading_numeric_prefix(s));
3247    }
3248    // v7.39 — a DECIMAL that will not fit clamps to the column's bound.
3249    //
3250    // Not a message fix: SPG REFUSED this even in a non-strict session,
3251    // where MySQL stores the bound and warns. Measured on 9.7.2 with
3252    // `sql_mode=''` and `DECIMAL(3,1)`:
3253    //
3254    //     9999  ->  99.9      -9999  ->  -99.9      1.26  ->  1.3
3255    //
3256    // The last is ordinary rounding to the declared scale and happens in
3257    // strict mode too; only the first two are the overflow. A bulk load
3258    // into a non-strict session stopped here on a row MySQL would have
3259    // taken.
3260    //
3261    // With the value bent, the STRICT path gets its answer from the same
3262    // classifier as every other type — `Out of range value for column 'd'
3263    // at row 1`, errno 1264 — instead of PostgreSQL's `numeric field
3264    // overflow`, which also carried its `DETAIL:` clause inline.
3265    if let DataType::Numeric { precision, scale } = schema.ty
3266        && precision != 0
3267        && scale >= 0
3268    {
3269        let col_scale = u16::try_from(scale).unwrap_or(0);
3270        let (scaled, val_scale) = match v {
3271            Value::Numeric {
3272                scaled,
3273                scale: vs,
3274                kind: spg_storage::NumericKind::Finite,
3275            } => (scaled, vs),
3276            Value::SmallInt(n) => (i128::from(n), 0),
3277            Value::Int(n) => (i128::from(n), 0),
3278            Value::BigInt(n) => (i128::from(n), 0),
3279            _ => return numeric_untouched(v, schema),
3280        };
3281        // Restate at the column's scale, rounding half away from zero as
3282        // MySQL does, then clamp to +/-(10^precision - 1) in those units.
3283        // Only a CLAMP is a value that would not fit. Rounding to the
3284        // declared scale is ordinary coercion and happens in strict mode
3285        // too: `DECIMAL(3,1) <- 1.26` stores `1.3` on both engines and in
3286        // both modes.
3287        //
3288        // The first cut returned the restated value unconditionally, and
3289        // the classifier — which decides "did not fit" by comparing before
3290        // with after — read that rounding as an overflow. Strict sessions
3291        // then REFUSED 1.26 with `Out of range value`, a value MySQL
3292        // takes. The six refusal probes were all green while that was
3293        // true; only checking the ACCEPTING side found it.
3294        let restated = restate_scaled(scaled, val_scale, col_scale);
3295        let limit = 10i128
3296            .checked_pow(u32::from(precision))
3297            .map_or(i128::MAX, |p| p - 1);
3298        if restated < -limit || restated > limit {
3299            return Value::numeric(restated.clamp(-limit, limit), col_scale);
3300        }
3301        return numeric_untouched(v, schema);
3302    }
3303    // An out-of-range integer clamps to the column's bound.
3304    let as_int = match v {
3305        Value::SmallInt(n) => Some(i128::from(n)),
3306        Value::Int(n) => Some(i128::from(n)),
3307        Value::BigInt(n) => Some(i128::from(n)),
3308        // v7.39 (round 471) — a BIGINT UNSIGNED cell arrives as Numeric.
3309        Value::Numeric {
3310            scaled, scale: 0, ..
3311        } => Some(scaled),
3312        _ => None,
3313    };
3314    if let Some(n) = as_int
3315        && let Some((lo, hi)) = column_int_bounds(schema)
3316        && (n < lo || n > hi)
3317    {
3318        return int_value_for_column(n.clamp(lo, hi));
3319    }
3320    // An over-long string truncates to the declared length.
3321    if let Value::Text(ref s) = v {
3322        let max = match schema.ty {
3323            DataType::Varchar(m) | DataType::Char(m) if m > 0 => m as usize,
3324            _ => return v,
3325        };
3326        if s.chars().count() > max {
3327            return Value::text(s.chars().take(max).collect::<alloc::string::String>());
3328        }
3329    }
3330    v
3331}
3332
3333/// MySQL's string → integer coercion: take the longest leading numeric
3334/// prefix, read it as a double, and round half AWAY FROM ZERO. Measured on
3335/// MariaDB 11 — `'3.7abc'` → 4, `'2.4'` → 2, `'2.5'` → 3, `'-2.5'` → -3,
3336/// `'1e3x'` → 1000, `'0x10'` → 0 (the prefix is just the leading `0`),
3337/// `'abc'` / `'-'` / `''` → 0.
3338///
3339/// The prefix is a float, not an integer: reading only digits would answer 0
3340/// for `'.5'` where MySQL answers 1.
3341fn leading_numeric_prefix(s: &str) -> i64 {
3342    let t = s.trim_start();
3343    let b = t.as_bytes();
3344    let mut i = 0;
3345    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
3346        i += 1;
3347    }
3348    let int_start = i;
3349    while i < b.len() && b[i].is_ascii_digit() {
3350        i += 1;
3351    }
3352    let mut end = i;
3353    if i < b.len() && b[i] == b'.' {
3354        i += 1;
3355        while i < b.len() && b[i].is_ascii_digit() {
3356            i += 1;
3357        }
3358        // A lone "." after the sign is not a number; digits on either side
3359        // of it are.
3360        if i > int_start + 1 {
3361            end = i;
3362        }
3363    }
3364    // An exponent only counts when it has at least one digit AND a mantissa.
3365    if end > int_start && i < b.len() && (b[i] == b'e' || b[i] == b'E') {
3366        let mut j = i + 1;
3367        if j < b.len() && (b[j] == b'-' || b[j] == b'+') {
3368            j += 1;
3369        }
3370        let digits_start = j;
3371        while j < b.len() && b[j].is_ascii_digit() {
3372            j += 1;
3373        }
3374        if j > digits_start {
3375            end = j;
3376        }
3377    }
3378    let Ok(f) = t[..end].parse::<f64>() else {
3379        return 0;
3380    };
3381    // `f64::round` is already half-away-from-zero, which is MySQL's rule.
3382    let r = f.round();
3383    if r >= i64::MAX as f64 {
3384        i64::MAX
3385    } else if r <= i64::MIN as f64 {
3386        i64::MIN
3387    } else {
3388        r as i64
3389    }
3390}
3391
3392/// v7.39 (round 471) — the Value an integer takes when it may exceed i64.
3393/// Mirrors `eval::u64_as_value`: BigInt while it fits, Numeric (scale 0)
3394/// past it, which is how a BIGINT UNSIGNED cell is stored.
3395fn int_value_for_column(n: i128) -> Value<'static> {
3396    match i64::try_from(n) {
3397        Ok(v) => Value::BigInt(v),
3398        Err(_) => Value::numeric(n, 0),
3399    }
3400}
3401
3402pub(crate) fn check_unsigned_range(
3403    v: &Value,
3404    schema: &ColumnSchema,
3405    position: usize,
3406) -> Result<(), EngineError> {
3407    let n: i128 = match v {
3408        Value::SmallInt(x) => i128::from(*x),
3409        Value::Int(x) => i128::from(*x),
3410        Value::BigInt(x) => i128::from(*x),
3411        // v7.39 (round 471) — a BIGINT UNSIGNED cell arrives as Numeric,
3412        // which is the whole reason the bounds are i128 now.
3413        Value::Numeric { scaled, scale, .. } if *scale == 0 => *scaled,
3414        _ => return Ok(()), // non-integer cells (NULL, default) skip
3415    };
3416    // TINYINT / MEDIUMINT: the storage tag (SmallInt / Int) is wider than
3417    // the declared MySQL type, so the real bounds are enforced here. The
3418    // unsigned variant's 0 lower bound also covers the negative check.
3419    if let Some(width) = schema.mysql_int_width {
3420        // Small / Int are only ever set on an UNSIGNED column (a signed
3421        // SMALLINT / INT keeps its faithful storage tag and no marker); the
3422        // signed arms are unreachable but keep the match total.
3423        // v7.39 (round 471) — one bounds table, not two. The copy here
3424        // drifted out of reach the moment BIGINT UNSIGNED needed i128.
3425        let _ = width;
3426        let (lo, hi) = column_int_bounds(schema).unwrap_or((i128::MIN, i128::MAX));
3427        if n < lo || n > hi {
3428            // MariaDB's wording (SQLSTATE 22003); SPG tracks the column,
3429            // not the multi-row row number the "at row N" suffix carries.
3430            return Err(EngineError::Unsupported(alloc::format!(
3431                "Out of range value for column '{}'",
3432                schema.name
3433            )));
3434        }
3435        return Ok(());
3436    }
3437    // Other columns: reject a negative on any UNSIGNED column (Phase 4.4).
3438    if schema.is_unsigned && n < 0 {
3439        return Err(EngineError::Unsupported(alloc::format!(
3440            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
3441            schema.name
3442        )));
3443    }
3444    Ok(())
3445}
3446
3447/// Coerce a non-empty `TEXT[]` (how an array literal reaches a typed-array
3448/// cast) into a typed array by parsing each element through the existing
3449/// scalar `coerce_value` path. NULL elements pass through. Returns `None` for
3450/// array targets this helper does not cover (leaving the caller's other arms
3451/// or the final type-mismatch to handle it).
3452fn coerce_text_array_to(
3453    items: alloc::vec::Vec<Option<alloc::string::String>>,
3454    target: DataType,
3455    col: &str,
3456) -> Result<Option<Value<'static>>, EngineError> {
3457    let elem_dt = match target {
3458        DataType::BoolArray => DataType::Bool,
3459        DataType::NumericArray => DataType::Numeric {
3460            precision: 0,
3461            scale: 0,
3462        },
3463        DataType::DateArray => DataType::Date,
3464        DataType::TimestampArray => DataType::Timestamp,
3465        DataType::TimestamptzArray => DataType::Timestamptz,
3466        DataType::UuidArray => DataType::Uuid,
3467        // v7.39 (round 326, V43) — INTERVAL[] joined the covered set;
3468        // `'{1 day}'::interval[]` used to fail as a plain type mismatch.
3469        DataType::IntervalArray => DataType::Interval,
3470        _ => return Ok(None),
3471    };
3472    let mut scal: alloc::vec::Vec<Option<Value<'static>>> =
3473        alloc::vec::Vec::with_capacity(items.len());
3474    for item in items {
3475        match item {
3476            None => scal.push(None),
3477            Some(s) => scal.push(Some(coerce_value(Value::text(s), elem_dt, col, 0)?)),
3478        }
3479    }
3480    let out = match target {
3481        DataType::BoolArray => Value::BoolArray(
3482            scal.into_iter()
3483                .map(|o| o.map(|v| matches!(v, Value::Bool(true))))
3484                .collect(),
3485        ),
3486        DataType::NumericArray => Value::NumericArray(
3487            scal.into_iter()
3488                .map(|o| {
3489                    o.map(|v| match v {
3490                        Value::Numeric { scaled, scale, .. } => (scaled, scale),
3491                        _ => (0, 0),
3492                    })
3493                })
3494                .collect(),
3495        ),
3496        DataType::DateArray => Value::DateArray(
3497            scal.into_iter()
3498                .map(|o| {
3499                    o.map(|v| match v {
3500                        Value::Date(d) => d,
3501                        _ => 0,
3502                    })
3503                })
3504                .collect(),
3505        ),
3506        DataType::TimestampArray => Value::TimestampArray(
3507            scal.into_iter()
3508                .map(|o| {
3509                    o.map(|v| match v {
3510                        Value::Timestamp(t) => t,
3511                        _ => 0,
3512                    })
3513                })
3514                .collect(),
3515        ),
3516        DataType::TimestamptzArray => Value::TimestamptzArray(
3517            scal.into_iter()
3518                .map(|o| {
3519                    o.map(|v| match v {
3520                        Value::Timestamp(t) => t,
3521                        _ => 0,
3522                    })
3523                })
3524                .collect(),
3525        ),
3526        DataType::UuidArray => Value::UuidArray(
3527            scal.into_iter()
3528                .map(|o| {
3529                    o.map(|v| match v {
3530                        Value::Uuid(u) => u,
3531                        _ => [0u8; 16],
3532                    })
3533                })
3534                .collect(),
3535        ),
3536        DataType::IntervalArray => Value::IntervalArray(
3537            scal.into_iter()
3538                .map(|o| {
3539                    o.and_then(|v| match v {
3540                        Value::Interval {
3541                            months,
3542                            days,
3543                            micros,
3544                            kind,
3545                        } => Some(spg_storage::IntervalSpan {
3546                            months,
3547                            days,
3548                            micros,
3549                            kind,
3550                        }),
3551                        _ => None,
3552                    })
3553                })
3554                .collect(),
3555        ),
3556        _ => return Ok(None),
3557    };
3558    Ok(Some(out))
3559}
3560
3561/// Parse a PG integer literal in text: decimal, plus the PG 16+ forms —
3562/// radix prefixes (`0x1F` hex / `0o17` octal / `0b101` binary) and `_` digit
3563/// separators (`1_000`). An optional leading sign applies to the magnitude.
3564/// Map a built-in type OID to its SQL-standard name, PG's `format_type`
3565/// / `oid::regtype` spelling (without the typmod). `None` for OIDs SPG
3566/// doesn't recognise (callers render the numeric OID, as PG does for an
3567/// unknown regtype). Shared by the `::regtype` cast and `format_type`.
3568/// v7.39 (read01 utils/adt, format_type.c) — the element OID for a
3569/// standard array type OID (PG's pg_type.typelem for the built-in `_x`
3570/// array types). format_type renders these as `<element>[]`.
3571pub(crate) fn array_oid_element(oid: i64) -> Option<i64> {
3572    Some(match oid {
3573        1000 => 16,   // _bool
3574        1001 => 17,   // _bytea
3575        1002 => 18,   // _char
3576        1003 => 19,   // _name
3577        1016 => 20,   // _int8
3578        1005 => 21,   // _int2
3579        1007 => 23,   // _int4
3580        1009 => 25,   // _text
3581        1028 => 26,   // _oid
3582        199 => 114,   // _json
3583        143 => 142,   // _xml
3584        651 => 650,   // _cidr
3585        1021 => 700,  // _float4
3586        1022 => 701,  // _float8
3587        775 => 774,   // _macaddr8
3588        791 => 790,   // _money
3589        1040 => 829,  // _macaddr
3590        1041 => 869,  // _inet
3591        1014 => 1042, // _bpchar
3592        1015 => 1043, // _varchar
3593        1182 => 1082, // _date
3594        1183 => 1083, // _time
3595        1115 => 1114, // _timestamp
3596        1185 => 1184, // _timestamptz
3597        1187 => 1186, // _interval
3598        1270 => 1266, // _timetz
3599        1561 => 1560, // _bit
3600        1563 => 1562, // _varbit
3601        1231 => 1700, // _numeric
3602        2951 => 2950, // _uuid
3603        3643 => 3614, // _tsvector
3604        3645 => 3615, // _tsquery
3605        3807 => 3802, // _jsonb
3606        _ => return None,
3607    })
3608}
3609
3610/// v7.39 (round 621) — the OID of an ARRAY reads back as `<element>[]`.
3611///
3612/// `1007::regtype` rendered the number `1007` instead of `integer[]`, so a
3613/// column-type query — `atttypid::regtype`, the shape this cast exists for —
3614/// told an ORM the type of every array column was a bare number. The scalar
3615/// OIDs were all there; only the array half was missing, from both directions.
3616pub(crate) fn regtype_oid_to_name_owned(oid: i64) -> Option<alloc::string::String> {
3617    if let Some(scalar) = regtype_oid_to_name(oid) {
3618        return Some(alloc::string::String::from(scalar));
3619    }
3620    let (_, _, elem) = crate::system_catalog::ARRAY_TYPE_OIDS
3621        .iter()
3622        .find(|(arr, _, _)| *arr == oid)?;
3623    Some(alloc::format!("{}[]", regtype_oid_to_name(*elem)?))
3624}
3625
3626/// The array OID whose element is `elem`, for the reverse direction.
3627pub(crate) fn array_oid_for_element(elem: i64) -> Option<i64> {
3628    crate::system_catalog::ARRAY_TYPE_OIDS
3629        .iter()
3630        .find(|(_, _, e)| *e == elem)
3631        .map(|(arr, _, _)| *arr)
3632}
3633
3634pub(crate) fn regtype_oid_to_name(oid: i64) -> Option<&'static str> {
3635    Some(match oid {
3636        4600 => "pg_brin_bloom_summary",
3637        16 => "boolean",
3638        17 => "bytea",
3639        18 => "\"char\"",
3640        19 => "name",
3641        20 => "bigint",
3642        21 => "smallint",
3643        23 => "integer",
3644        25 => "text",
3645        26 => "oid",
3646        // v7.39 (round 640) — the row-header types.
3647        27 => "tid",
3648        28 => "xid",
3649        29 => "cid",
3650        5069 => "xid8",
3651        114 => "json",
3652        142 => "xml",
3653        650 => "cidr",
3654        700 => "real",
3655        701 => "double precision",
3656        774 => "macaddr8",
3657        790 => "money",
3658        829 => "macaddr",
3659        869 => "inet",
3660        1042 => "character",
3661        1043 => "character varying",
3662        1082 => "date",
3663        1083 => "time without time zone",
3664        1114 => "timestamp without time zone",
3665        1184 => "timestamp with time zone",
3666        1186 => "interval",
3667        1266 => "time with time zone",
3668        1560 => "bit",
3669        1562 => "bit varying",
3670        1700 => "numeric",
3671        2950 => "uuid",
3672        3614 => "tsvector",
3673        3615 => "tsquery",
3674        3802 => "jsonb",
3675        3904 => "int4range",
3676        3906 => "numrange",
3677        3908 => "tsrange",
3678        3910 => "tstzrange",
3679        3912 => "daterange",
3680        3926 => "int8range",
3681        _ => return None,
3682    })
3683}
3684
3685pub(crate) fn parse_pg_int(s: &str) -> Option<i64> {
3686    let s = s.trim();
3687    let (neg, rest) = if let Some(r) = s.strip_prefix('-') {
3688        (true, r)
3689    } else if let Some(r) = s.strip_prefix('+') {
3690        (false, r)
3691    } else {
3692        (false, s)
3693    };
3694    // Split off an optional radix prefix (PG 16+: 0x / 0o / 0b), leaving
3695    // the digit portion. PG allows `_` group separators ONLY between two
3696    // digits — a leading/trailing/doubled underscore (`_5`, `5_`, `1__2`)
3697    // or one adjacent to the prefix is "invalid input syntax".
3698    let (radix, digits, has_prefix) =
3699        if let Some(h) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
3700            (16u32, h, true)
3701        } else if let Some(o) = rest.strip_prefix("0o").or_else(|| rest.strip_prefix("0O")) {
3702            (8, o, true)
3703        } else if let Some(b) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) {
3704            (2, b, true)
3705        } else {
3706            (10, rest, false)
3707        };
3708    let db = digits.as_bytes();
3709    // Reject a trailing or doubled underscore anywhere, and a leading
3710    // underscore unless it follows a radix prefix (PG accepts `0x_FF` but
3711    // not `_5` / `5_` / `1__2` / `0xFF_`).
3712    if db.last() == Some(&b'_')
3713        || digits.contains("__")
3714        || (!has_prefix && db.first() == Some(&b'_'))
3715    {
3716        return None;
3717    }
3718    let cleaned: alloc::string::String = digits.chars().filter(|&c| c != '_').collect();
3719    if cleaned.is_empty() {
3720        return None;
3721    }
3722    let mag = i64::from_str_radix(&cleaned, radix).ok()?;
3723    Some(if neg { mag.checked_neg()? } else { mag })
3724}
3725
3726/// v7.38 (read01 P6.38) — well-formedness check for PG's `xml` CONTENT mode.
3727/// Verifies element tags are balanced and properly nested; comments (`<!-- -->`),
3728/// processing instructions (`<? ?>`), CDATA sections, `<!DOCTYPE …>`, plain
3729/// text, self-closing tags and multiple top-level elements are all accepted.
3730/// Attribute values are quote-aware so a `>` inside an attribute doesn't end a
3731/// tag early. This catches the common malformedness (unclosed / mismatched
3732/// tags) libxml2 rejects; deeper libxml2 checks (entity validity, duplicate
3733/// attributes, char legality) are a documented follow-up.
3734fn xml_content_is_well_formed(s: &str) -> bool {
3735    let b = s.as_bytes();
3736    let is_name =
3737        |c: u8| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b':') || c >= 0x80;
3738    let mut stack: alloc::vec::Vec<&[u8]> = alloc::vec::Vec::new();
3739    let mut i = 0;
3740    while i < b.len() {
3741        if b[i] != b'<' {
3742            i += 1;
3743            continue;
3744        }
3745        let rest = &s[i..];
3746        if rest.starts_with("<!--") {
3747            match rest.find("-->") {
3748                Some(p) => i += p + 3,
3749                None => return false,
3750            }
3751        } else if rest.starts_with("<![CDATA[") {
3752            match rest.find("]]>") {
3753                Some(p) => i += p + 3,
3754                None => return false,
3755            }
3756        } else if rest.starts_with("<?") {
3757            match rest.find("?>") {
3758                Some(p) => i += p + 2,
3759                None => return false,
3760            }
3761        } else if rest.starts_with("<!") {
3762            match rest.find('>') {
3763                Some(p) => i += p + 1,
3764                None => return false,
3765            }
3766        } else {
3767            // Element open / close / self-close tag.
3768            let close = i + 1 < b.len() && b[i + 1] == b'/';
3769            let name_start = if close { i + 2 } else { i + 1 };
3770            let mut j = name_start;
3771            while j < b.len() && is_name(b[j]) {
3772                j += 1;
3773            }
3774            if j == name_start {
3775                return false; // `<` not followed by a tag name
3776            }
3777            let name = &b[name_start..j];
3778            // Scan to the matching `>`, skipping quoted attribute values.
3779            let mut k = j;
3780            let mut quote = 0u8;
3781            let mut prev = 0u8;
3782            loop {
3783                if k >= b.len() {
3784                    return false; // unterminated tag
3785                }
3786                let c = b[k];
3787                if quote != 0 {
3788                    if c == quote {
3789                        quote = 0;
3790                    }
3791                } else if c == b'"' || c == b'\'' {
3792                    quote = c;
3793                } else if c == b'>' {
3794                    break;
3795                }
3796                prev = c;
3797                k += 1;
3798            }
3799            let self_closing = prev == b'/';
3800            i = k + 1;
3801            if close {
3802                match stack.pop() {
3803                    Some(top) if top == name => {}
3804                    _ => return false,
3805                }
3806            } else if !self_closing {
3807                stack.push(name);
3808            }
3809        }
3810    }
3811    stack.is_empty()
3812}
3813
3814/// v7.38 (read01) — parse a float8 the way PG's `float8in` does: a
3815/// numeric literal that overflows to ±∞, or a nonzero magnitude that
3816/// underflows to 0, is "out of range" (returns None → the caller errors),
3817/// not a silent Infinity/0. The `inf`/`infinity`/`nan` spellings (a letter
3818/// after the optional sign) are the legitimate special values and pass.
3819pub(crate) fn parse_float8(s: &str) -> Option<f64> {
3820    let t = s.trim();
3821    let parsed = t.parse::<f64>().ok()?;
3822    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
3823    let numeric_looking = body
3824        .bytes()
3825        .next()
3826        .is_some_and(|c| c.is_ascii_digit() || c == b'.');
3827    if numeric_looking {
3828        if parsed.is_infinite() {
3829            return None; // overflow
3830        }
3831        if parsed == 0.0 {
3832            // A mantissa with a nonzero digit that resolves to 0 underflowed.
3833            let mantissa = body.split(['e', 'E']).next().unwrap_or(body);
3834            if mantissa.bytes().any(|c| c.is_ascii_digit() && c != b'0') {
3835                return None;
3836            }
3837        }
3838    }
3839    Some(parsed)
3840}
3841
3842/// v7.38 (read01) — decode PG's external array form (`{a,b,NULL}`) and coerce
3843/// each element to `elem` through `coerce_value`, so element semantics (bool
3844/// spellings, date formats, numeric parsing, float8 range) live in one place.
3845fn decode_array_elems(
3846    s: &str,
3847    elem: DataType,
3848    col_name: &str,
3849    position: usize,
3850) -> Result<Vec<Option<Value<'static>>>, EngineError> {
3851    // v7.39 (round 325, V57) — PG's wording. This path used to answer
3852    // `cannot parse "abc" as an array: TEXT[] literal must be enclosed in
3853    // '{...}'` — SPG's own phrasing, naming TEXT[] even for an INT[]
3854    // column, and differing from what the `::int[]` CAST path already
3855    // said for the very same input.
3856    let raw = decode_text_array_literal(s).map_err(|_| {
3857        EngineError::Eval(EvalError::TypeMismatch {
3858            detail: malformed_array_literal(s),
3859        })
3860    })?;
3861    let mut out = Vec::with_capacity(raw.len());
3862    for e in raw {
3863        match e {
3864            None => out.push(None),
3865            Some(t) => out.push(Some(coerce_value(
3866                Value::text(t),
3867                elem,
3868                col_name,
3869                position,
3870            )?)),
3871        }
3872    }
3873    Ok(out)
3874}
3875
3876/// v7.39 (read01 round 54) — coerce a value whose `data_type()` is None (the
3877/// eval-only variants: RegClass carries an oid + name, Composite a field
3878/// tuple). They used to panic in `coerce_value`.
3879fn coerce_untyped_value(
3880    v: Value<'static>,
3881    expected: DataType,
3882    col_name: &str,
3883    position: usize,
3884) -> Result<Value<'static>, EngineError> {
3885    match (&v, expected) {
3886        // A regclass IS an oid — it coerces to any integer width, and to text
3887        // through its relation name.
3888        //
3889        // v7.39 (round 667) — `DataType::Oid` is listed with BigInt here and
3890        // is not optional. Giving the `oid` name its own DataType turned
3891        // `'text'::regtype::oid` from a coercion into a column of type
3892        // BIGINT into one of type OID, and nine catalog tests went red at
3893        // once. This is the THIRD list that has to name the reg* trio
3894        // together; the other two are the bigint materialiser below and the
3895        // classifier that decides a value is reg-shaped.
3896        (
3897            Value::RegClass(oid, _) | Value::RegProc(oid, _) | Value::RegType(oid, _),
3898            DataType::BigInt | DataType::Oid,
3899        ) => Ok(Value::BigInt(*oid)),
3900        (
3901            Value::RegClass(oid, _) | Value::RegProc(oid, _) | Value::RegType(oid, _),
3902            DataType::Int,
3903        ) => Ok(Value::Int(i32::try_from(*oid).unwrap_or(i32::MAX))),
3904        (
3905            Value::RegClass(_, name) | Value::RegProc(_, name) | Value::RegType(_, name),
3906            DataType::Text,
3907        ) => Ok(Value::text(alloc::string::String::from(name.as_ref()))),
3908        // v7.39 (read01 round 55) — SPG stores a composite-typed column as
3909        // JSON (an object keyed by field name), so a real Composite value —
3910        // which is what `ROW(1,2)::pt` now produces — coerces into it. Before
3911        // this the cast resolved but the INSERT died on "cannot coerce
3912        // Composite(...) to Jsonb".
3913        (Value::Composite(fields), DataType::Jsonb | DataType::Json) => {
3914            let mut obj = alloc::string::String::from("{");
3915            for (i, (name, val)) in fields.iter().enumerate() {
3916                if i > 0 {
3917                    obj.push(',');
3918                }
3919                // Reuse the JSON encoder for the key so escaping is identical.
3920                obj.push_str(&crate::json::value_to_json_text(&Value::text(
3921                    alloc::string::String::from(name.as_str()),
3922                )));
3923                obj.push(':');
3924                obj.push_str(&crate::json::value_to_json_text(val));
3925            }
3926            obj.push('}');
3927            Ok(Value::Json(alloc::borrow::Cow::Owned(obj)))
3928        }
3929        // …and its canonical PG text form for a text column.
3930        (Value::Composite(_), DataType::Text) => Ok(Value::text(crate::eval::value_to_text(&v))),
3931        _ => Err(EngineError::Unsupported(alloc::format!(
3932            "cannot coerce {:?} to {expected:?} for column {col_name:?} (position {position})",
3933            v
3934        ))),
3935    }
3936}
3937
3938/// v7.39 (read01 round 90) — PG's 22P02 for a text value that will not parse as
3939/// the target type: `invalid input syntax for type <T>: "<value>"`. The type
3940/// word is PG's own spelling (`integer`, `double precision`, `boolean`, …).
3941fn invalid_input_syntax(ty: &str, value: &str) -> EngineError {
3942    EngineError::Eval(EvalError::TypeMismatch {
3943        detail: alloc::format!("invalid input syntax for type {ty}: \"{value}\""),
3944    })
3945}
3946
3947/// v7.39 (round 269) — PG quotes the offending source when it has one:
3948/// `"1e40" is out of range for type real`.
3949fn real_out_of_range(value: &str) -> EngineError {
3950    float_out_of_range(value, "real")
3951}
3952
3953/// v7.39 (round 270) — the same for either float width.
3954fn float_out_of_range(value: &str, ty: &str) -> EngineError {
3955    EngineError::Eval(EvalError::TypeMismatch {
3956        detail: alloc::format!("\"{value}\" is out of range for type {ty}"),
3957    })
3958}
3959
3960/// v7.39 (round 270) — a float text that `parse_float8` rejected is
3961/// either not a number at all or a number outside the type's range, and
3962/// PG words the two differently. `parse_float8` already distinguishes
3963/// them internally (it returns None for a numeric-looking infinity or a
3964/// nonzero mantissa that underflowed to zero); this recovers which.
3965fn float_text_error(s: &str, ty: &str) -> EngineError {
3966    let t = s.trim();
3967    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
3968    let numeric_looking = body
3969        .bytes()
3970        .next()
3971        .is_some_and(|c| c.is_ascii_digit() || c == b'.');
3972    if numeric_looking && t.parse::<f64>().is_ok() {
3973        float_out_of_range(t, ty)
3974    } else {
3975        invalid_input_syntax(ty, s)
3976    }
3977}
3978
3979/// Whether a float text names a nonzero value: a mantissa carrying any
3980/// digit other than 0. Underflowing such a source to zero is an error
3981/// in PG, while `'0'` really is zero.
3982fn float_text_is_nonzero(t: &str) -> bool {
3983    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
3984    let mantissa = body.split(['e', 'E']).next().unwrap_or(body);
3985    mantissa.bytes().any(|c| c.is_ascii_digit() && c != b'0')
3986}
3987
3988/// Whether a float text literally spells an infinity, which PG accepts
3989/// as a value rather than treating as an overflow.
3990fn text_is_explicit_infinity(t: &str) -> bool {
3991    let t = t.trim_start_matches(['+', '-']);
3992    t.eq_ignore_ascii_case("inf") || t.eq_ignore_ascii_case("infinity")
3993}
3994
3995/// v7.39 (read01 round 90) — PG splits a failed date/time text into two states:
3996/// a date-shaped string whose fields are out of range (month 13, day 30) is
3997/// 22008 `date/time field value out of range: "X"`; anything not date-shaped is
3998/// 22007 `invalid input syntax for type <T>: "X"`. SPG's parsers return a single
3999/// None, so classify by shape here — runs ONLY on an already-failed parse, so it
4000/// only ever picks between two error strings, never changes behaviour. A string
4001/// of date punctuation (digits, `- / : . space`, `+`, `T`) with at least one
4002/// digit is treated as "well-formed but out of range".
4003fn datetime_parse_error(ty: &str, s: &str) -> EngineError {
4004    let t = s.trim();
4005    let date_shaped = t.chars().any(|c| c.is_ascii_digit())
4006        && t.chars().all(|c| {
4007            c.is_ascii_digit() || matches!(c, '-' | '/' | ':' | '.' | ' ' | '+' | 'T' | 't')
4008        });
4009    let detail = if date_shaped {
4010        alloc::format!("date/time field value out of range: \"{t}\"")
4011    } else {
4012        alloc::format!("invalid input syntax for type {ty}: \"{t}\"")
4013    };
4014    EngineError::Eval(EvalError::TypeMismatch { detail })
4015}
4016
4017/// v7.39 (read01 round 113) — the underlying scalar of a `jsonb` value being
4018/// cast to a numeric or boolean target. PG decodes it first: a JSON number
4019/// becomes an unconstrained NUMERIC (so int targets round half-away, matching
4020/// `2.5::numeric::int` = 3), true/false become bool, `null` becomes SQL NULL.
4021/// A JSON string / array / object is not castable to any scalar target.
4022pub(crate) enum JsonbScalar {
4023    Numeric(Value<'static>),
4024    Bool(bool),
4025    Null,
4026}
4027
4028/// PG's "cannot cast jsonb <kind> to type <target>" (SQLSTATE 22023).
4029pub(crate) fn jsonb_cast_type_error(kind: &str, target: &str) -> EvalError {
4030    EvalError::TypeMismatch {
4031        detail: alloc::format!("cannot cast jsonb {kind} to type {target}"),
4032    }
4033}
4034
4035/// Decode a serialized `jsonb` scalar for a numeric/bool cast. `target` names
4036/// the SQL type only for the error text on the non-scalar kinds.
4037pub(crate) fn jsonb_scalar_for_cast(s: &str, target: &str) -> Result<JsonbScalar, EvalError> {
4038    use crate::json::JsonValue;
4039    match crate::json::parse(s) {
4040        Ok(JsonValue::Null) => Ok(JsonbScalar::Null),
4041        Ok(JsonValue::Bool(b)) => Ok(JsonbScalar::Bool(b)),
4042        // Route the number through the unconstrained NUMERIC input path so the
4043        // integer targets inherit PG's numeric (half-away) rounding + range
4044        // errors, and scientific / big forms are handled once, centrally.
4045        Ok(JsonValue::Number(x)) => {
4046            let num = coerce_value(
4047                Value::text(alloc::format!("{x}")),
4048                DataType::Numeric {
4049                    precision: 0,
4050                    scale: 0,
4051                },
4052                "",
4053                0,
4054            )
4055            .map_err(|e| match e {
4056                EngineError::Eval(ev) => ev,
4057                _ => jsonb_cast_type_error("numeric", target),
4058            })?;
4059            Ok(JsonbScalar::Numeric(num))
4060        }
4061        Ok(JsonValue::NumberText(text)) => {
4062            let num = coerce_value(
4063                Value::text(text),
4064                DataType::Numeric {
4065                    precision: 0,
4066                    scale: 0,
4067                },
4068                "",
4069                0,
4070            )
4071            .map_err(|e| match e {
4072                EngineError::Eval(ev) => ev,
4073                _ => jsonb_cast_type_error("numeric", target),
4074            })?;
4075            Ok(JsonbScalar::Numeric(num))
4076        }
4077        Ok(JsonValue::String(_)) => Err(jsonb_cast_type_error("string", target)),
4078        Ok(JsonValue::Array(_)) => Err(jsonb_cast_type_error("array", target)),
4079        Ok(JsonValue::Object(_)) => Err(jsonb_cast_type_error("object", target)),
4080        Err(_) => Err(jsonb_cast_type_error("value", target)),
4081    }
4082}
4083/// v7.39 (round 263) — normalise a value being written into a COMPOSITE
4084/// column before the generic coercion runs.
4085///
4086/// A composite column stores JSON keyed by FIELD NAME, and the field
4087/// names are PG-observable (`row_to_json(col)` keys by them, probed).
4088/// Two inputs reached the column without ever being labelled by the
4089/// target type:
4090///   * `ROW('elm', 999)` carries the constructor's placeholder names
4091///     `f1`/`f2`, so the stored object had the wrong keys and the read
4092///     side — which looks fields up BY NAME — rebuilt an all-NULL
4093///     record: silent data loss, `(elm,999)` came back as `(,)`.
4094///   * a record TEXT literal (`'("oak ave",111)'`) was stored verbatim,
4095///     which is not JSON at all, so the read side's parse failed and
4096///     field access errored.
4097/// Relabelling through the declared type also COERCES each field to its
4098/// declared type, which is what refuses `ROW('x','notanint')::addr`.
4099/// Returns the value untouched for a non-composite column.
4100pub(crate) fn normalize_composite_for_column(
4101    v: Value<'static>,
4102    col: &ColumnSchema,
4103    catalog: Option<&spg_storage::Catalog>,
4104) -> Result<Value<'static>, EngineError> {
4105    let Some(tname) = col.user_composite_type.as_deref() else {
4106        return Ok(v);
4107    };
4108    if matches!(v, Value::Null) {
4109        return Ok(v);
4110    }
4111    // No catalog in scope degrades to the previous behaviour rather than
4112    // erroring, matching how the read-side rehydration handles it.
4113    let Some(def) = catalog.and_then(|c| c.composite_types().get(tname)) else {
4114        return Ok(v);
4115    };
4116    // An already-labelled Composite still goes through so its fields get
4117    // coerced; a Json value is already in storage form.
4118    if matches!(v, Value::Json(_)) {
4119        return Ok(v);
4120    }
4121    crate::eval::apply_composite_cast_pub(v, def, catalog).map_err(EngineError::Eval)
4122}
4123
4124/// Coerce a `jsonb` value to a scalar numeric/bool `expected`. Returns `None`
4125/// when `expected` is not one of those targets (so the caller falls through to
4126/// the ordinary coercion table).
4127fn try_coerce_json_scalar(
4128    s: &str,
4129    expected: DataType,
4130    col_name: &str,
4131    position: usize,
4132) -> Option<Result<Value<'static>, EngineError>> {
4133    let target = match expected {
4134        DataType::Int => "integer",
4135        DataType::BigInt => "bigint",
4136        DataType::SmallInt => "smallint",
4137        DataType::Numeric { .. } => "numeric",
4138        DataType::Real => "real",
4139        DataType::Float => "double precision",
4140        DataType::Bool => "boolean",
4141        _ => return None,
4142    };
4143    Some(
4144        (|| match jsonb_scalar_for_cast(s, target).map_err(EngineError::Eval)? {
4145            JsonbScalar::Null => Ok(Value::Null),
4146            JsonbScalar::Bool(b) => {
4147                if matches!(expected, DataType::Bool) {
4148                    Ok(Value::Bool(b))
4149                } else {
4150                    Err(EngineError::Eval(jsonb_cast_type_error("boolean", target)))
4151                }
4152            }
4153            JsonbScalar::Numeric(n) => {
4154                if matches!(expected, DataType::Bool) {
4155                    Err(EngineError::Eval(jsonb_cast_type_error("numeric", target)))
4156                } else {
4157                    coerce_value(n, expected, col_name, position)
4158                }
4159            }
4160        })(),
4161    )
4162}
4163
4164/// v7.39 (round 367, M20 P2) — in the MySQL dialect a binary-string
4165/// literal (`0x…` / `X'…'` / `b'…'`, backed by `Value::Bytes`) coerces to
4166/// the target column like MariaDB does: into a BINARY / BLOB column it
4167/// stays bytes (handled by `coerce_value` itself); into a NUMERIC column
4168/// it is the bytes' big-endian integer (`INSERT … VALUES (0x10)` stores
4169/// 16); into a CHAR / VARCHAR / TEXT column it is the bytes read as a
4170/// latin-1 string (`0x4546` → 'EF'). A PostgreSQL session never produces
4171/// a `Value::Bytes` from these literals, so this only fires under the
4172/// dialect and leaves every other value untouched.
4173pub(crate) fn mysql_bytes_for_column(
4174    v: Value<'static>,
4175    expected: DataType,
4176    mysql: bool,
4177) -> Value<'static> {
4178    if !mysql {
4179        return v;
4180    }
4181    let Value::Bytes(ref b) = v else {
4182        return v;
4183    };
4184    match expected {
4185        DataType::SmallInt
4186        | DataType::Int
4187        | DataType::BigInt
4188        | DataType::Float
4189        | DataType::Real
4190        | DataType::Numeric { .. } => {
4191            let start = b.len().saturating_sub(16);
4192            let acc = b[start..]
4193                .iter()
4194                .fold(0u128, |a, &x| (a << 8) | u128::from(x));
4195            if acc <= i64::MAX as u128 {
4196                #[allow(clippy::cast_possible_truncation)]
4197                Value::BigInt(acc as i64)
4198            } else {
4199                big_literal_to_value(&alloc::format!("{acc}"))
4200            }
4201        }
4202        DataType::Text | DataType::Varchar(_) | DataType::Char(_) => Value::text(
4203            b.iter()
4204                .map(|&x| x as char)
4205                .collect::<alloc::string::String>(),
4206        ),
4207        _ => v,
4208    }
4209}
4210
4211/// v7.39 (round 544) — `timetz → time` and `interval → time`.
4212///
4213/// Measured on PG18:
4214///
4215/// ```text
4216///     '10:20:30.5'::timetz::time      10:20:30.5   (the zone is dropped,
4217///                                                   the wall clock kept)
4218///     '25:00:00'::interval::time      01:00:00     (modulo 24 hours)
4219///     '-1 hour'::interval::time       23:00:00     (and negatives wrap)
4220///     '1 day 02:00:00'::interval::time 02:00:00    (days do not count)
4221/// ```
4222///
4223/// `time → timetz` and `timestamp(tz) → time` are NOT here: the first
4224/// needs the session zone to attach, and the second needs to know which
4225/// of the two timestamp types the source was — `Value::Timestamp` is
4226/// the same variant for both, so answering from the value would be
4227/// right for `timestamp` and off by the session offset for
4228/// `timestamptz`. An error beats a silent wrong answer.
4229fn try_coerce_time_family(
4230    v: &Value<'static>,
4231    expected: DataType,
4232) -> Option<Result<Value<'static>, EngineError>> {
4233    const DAY_US: i64 = 86_400_000_000;
4234    if expected != DataType::Time {
4235        return None;
4236    }
4237    match v {
4238        Value::TimeTz { us, .. } => Some(Ok(Value::Time(*us))),
4239        Value::Interval { micros, .. } => Some(Ok(Value::Time(micros.rem_euclid(DAY_US)))),
4240        _ => None,
4241    }
4242}
4243
4244/// Normalise a value into PG's `oid` domain, or `Ok(None)` when the value is
4245/// not something an oid can be made from.
4246///
4247/// v7.39 (round 667) — extracted rather than copied. The rules lived inline
4248/// in the `::oid` cast and were already right (a negative wraps the way C's
4249/// `(Oid)` cast does, past `u32::MAX` is "OID out of range", bad text is
4250/// PG's 22P02 wording). Assigning INTO an oid column needed the same rules,
4251/// and round 665 had just finished paying for four hand-copies of one
4252/// accumulator, so this is one function with two callers instead.
4253pub(crate) fn coerce_to_oid(v: &Value<'_>) -> Result<Option<Value<'static>>, EvalError> {
4254    let as_i64 = match v {
4255        Value::Null => return Ok(Some(Value::Null)),
4256        Value::SmallInt(n) => i64::from(*n),
4257        Value::Int(n) => i64::from(*n),
4258        Value::BigInt(n) => *n,
4259        Value::Text(t) => match t.trim().parse::<i64>() {
4260            Ok(n) => n,
4261            Err(_) => {
4262                return Err(EvalError::TypeMismatch {
4263                    detail: alloc::format!("invalid input syntax for type oid: {:?}", t.trim()),
4264                });
4265            }
4266        },
4267        _ => return Ok(None),
4268    };
4269    // 32-bit wrap for negatives (C cast semantics).
4270    if (-(1i64 << 31)..0).contains(&as_i64) {
4271        return Ok(Some(Value::BigInt(as_i64 + (1i64 << 32))));
4272    }
4273    if !(0..=i64::from(u32::MAX)).contains(&as_i64) {
4274        return Err(EvalError::TypeMismatch {
4275            detail: "OID out of range".into(),
4276        });
4277    }
4278    Ok(Some(Value::BigInt(as_i64)))
4279}
4280
4281pub(crate) fn coerce_value(
4282    v: Value<'static>,
4283    expected: DataType,
4284    col_name: &str,
4285    position: usize,
4286) -> Result<Value<'static>, EngineError> {
4287    if v.is_null() {
4288        return Ok(Value::Null);
4289    }
4290    // v7.39 (read01 round 113) — a jsonb value cast to a scalar numeric/bool
4291    // target decodes its underlying JSON scalar first (PG's jsonb → int/bigint/
4292    // smallint/numeric/real/float8/bool casts). Json → Json still takes the
4293    // identity fast-path below; this only fires for the scalar targets.
4294    if let Value::Json(ref s) = v {
4295        if let Some(res) = try_coerce_json_scalar(s, expected, col_name, position) {
4296            return res;
4297        }
4298    }
4299    // v7.39 (round 544) — the temporal conversions PG performs and SPG
4300    // refused outright. Found by comparing a probe of SPG's own cast
4301    // function against PG18's pg_cast; see synth_pg_cast's note.
4302    if let Some(res) = try_coerce_time_family(&v, expected) {
4303        return res;
4304    }
4305    // v7.39 (read01 round 54) — `data_type()` is None for the eval-only
4306    // variants that carry no DataType (RegClass, Composite): they are NOT
4307    // NULL, so the old `.expect("non-null")` PANICKED on them. A regclass
4308    // reaching a coercion (e.g. `EXISTS (SELECT 1 WHERE oid_col = 't'::regclass)`,
4309    // which coerces the subquery's row) crashed the query with an
4310    // "internal error" instead of comparing by oid. Fall through to the
4311    // coercion table, which handles the shapes it knows and errors cleanly
4312    // on the rest.
4313    // v7.39 (round 254) — a NUMERIC special (NaN / ±Infinity) crossing a
4314    // cast: every arm below rebuilds its result from `scaled`/`scale`
4315    // with `kind: Finite`, which silently turned a special into 0
4316    // (`'Infinity'::numeric::float8` = 0). PG's table, probed live:
4317    // float8 / real pass the special through; the integer targets refuse
4318    // it; an unconstrained numeric keeps it, and a typmod'd numeric takes
4319    // NaN but overflows on an infinity.
4320    if let Value::Numeric { kind, .. } = v
4321        && kind != spg_storage::NumericKind::Finite
4322    {
4323        use spg_storage::NumericKind as K;
4324        let as_f64 = match kind {
4325            K::NaN => f64::NAN,
4326            K::PosInf => f64::INFINITY,
4327            K::NegInf => f64::NEG_INFINITY,
4328            K::Finite => unreachable!("checked above"),
4329        };
4330        // PG names any infinity "infinity" here, sign included.
4331        let what = if kind == K::NaN { "NaN" } else { "infinity" };
4332        let int_err = |target: &str| {
4333            Err(EngineError::Eval(EvalError::TypeMismatch {
4334                detail: alloc::format!("cannot convert {what} to {target}"),
4335            }))
4336        };
4337        match expected {
4338            DataType::Float => return Ok(Value::Float(as_f64)),
4339            #[allow(clippy::cast_possible_truncation)]
4340            DataType::Real => return Ok(Value::Real(as_f64 as f32)),
4341            DataType::Int => return int_err("integer"),
4342            DataType::BigInt => return int_err("bigint"),
4343            DataType::SmallInt => return int_err("smallint"),
4344            DataType::Numeric { precision, scale } => {
4345                // Unconstrained numeric (the 0/0 sentinel) keeps the
4346                // special; a declared precision overflows on an infinity
4347                // but still accepts NaN (PG: NaN has no magnitude).
4348                if precision != 0 && kind != K::NaN {
4349                    return Err(EngineError::Eval(EvalError::TypeMismatch {
4350                        detail: alloc::string::String::from("numeric field overflow"),
4351                    }));
4352                }
4353                let _ = scale;
4354                return Ok(v);
4355            }
4356            _ => {}
4357        }
4358    }
4359    // v7.39 (round 254) — the reverse direction: an IEEE special arriving
4360    // from float8 / real becomes the NUMERIC special (PG accepts it since
4361    // 14); the finite path below cannot represent one.
4362    if let DataType::Numeric { precision, .. } = expected {
4363        let f = match v {
4364            Value::Float(f) if !f.is_finite() => Some(f),
4365            #[allow(clippy::cast_lossless)]
4366            Value::Real(f) if !f.is_finite() => Some(f as f64),
4367            _ => None,
4368        };
4369        if let Some(f) = f {
4370            use spg_storage::NumericKind as K;
4371            if f.is_nan() {
4372                return Ok(Value::numeric_special(K::NaN));
4373            }
4374            if precision != 0 {
4375                return Err(EngineError::Eval(EvalError::TypeMismatch {
4376                    detail: alloc::string::String::from("numeric field overflow"),
4377                }));
4378            }
4379            return Ok(Value::numeric_special(if f > 0.0 {
4380                K::PosInf
4381            } else {
4382                K::NegInf
4383            }));
4384        }
4385    }
4386    let Some(actual) = v.data_type() else {
4387        return coerce_untyped_value(v, expected, col_name, position);
4388    };
4389    if actual == expected {
4390        return Ok(v);
4391    }
4392    // v7.38.8 — text reaching a json/jsonb column is validated here, the
4393    // way PG validates at its own input boundary, and reports what PG
4394    // reports when it will not parse.
4395    //
4396    // It was not validated at all, and the comment where the coercion
4397    // used to live said so outright: "no structural validation — the
4398    // responsibility for valid JSON lies with the producer". The jsonb
4399    // side went further and swallowed the parse error, storing the raw
4400    // text when canonicalisation failed. So `INSERT INTO t VALUES
4401    // ('{bad')` into a jsonb column was accepted where PG18 answers
4402    // `invalid input syntax for type json`, and every later read of
4403    // that row raised instead — including, in v7.38.7, one on the
4404    // checkpoint thread, which is the worst place for it: writes keep
4405    // being acknowledged while nothing reaches disk.
4406    //
4407    // Handled ahead of the match so the message names the real problem.
4408    // Reported through the generic path it read `expected Jsonb, actual
4409    // Text`, which describes a coercion that is ordinarily fine and
4410    // says nothing about the document being malformed.
4411    //
4412    // This boundary is also what the accessors now rest on: with it
4413    // enforced, a `Value::Json` is valid by construction, and `->>`
4414    // stops parsing the whole document once per row to find that out.
4415    if matches!(expected, DataType::Json | DataType::Jsonb)
4416        && let Value::Text(ref s) | Value::Json(ref s) = v
4417    {
4418        let bad = || {
4419            EngineError::Eval(crate::eval::EvalError::TypeMismatch {
4420                detail: alloc::string::String::from("invalid input syntax for type json"),
4421            })
4422        };
4423        return if expected == DataType::Jsonb {
4424            crate::json::canonicalize_jsonb(s.as_ref())
4425                .map(Value::json)
4426                .map_err(|_| bad())
4427        } else {
4428            crate::json::parse(s.as_ref())
4429                .map_err(|_| bad())
4430                .map(|_| Value::json(s.clone()))
4431        };
4432    }
4433    let coerced: Option<Value<'static>> = match (v, expected) {
4434        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
4435        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
4436        // v7.39 (read01 int.c) — a narrowing overflow is PG's typed
4437        // "smallint out of range" (22003), not a generic type mismatch.
4438        (Value::Int(n), DataType::SmallInt) => match i16::try_from(n) {
4439            Ok(v) => Some(Value::SmallInt(v)),
4440            Err(_) => {
4441                return Err(EngineError::Eval(EvalError::TypeMismatch {
4442                    detail: "smallint out of range".into(),
4443                }));
4444            }
4445        },
4446        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
4447            i128::from(n),
4448            precision,
4449            scale,
4450            col_name,
4451        )?),
4452        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
4453        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
4454        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
4455        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
4456            i128::from(n),
4457            precision,
4458            scale,
4459            col_name,
4460        )?),
4461        (Value::BigInt(n), DataType::Int) => match i32::try_from(n) {
4462            Ok(v) => Some(Value::Int(v)),
4463            Err(_) => {
4464                return Err(EngineError::Eval(EvalError::TypeMismatch {
4465                    detail: "integer out of range".into(),
4466                }));
4467            }
4468        },
4469        (Value::BigInt(n), DataType::SmallInt) => match i16::try_from(n) {
4470            Ok(v) => Some(Value::SmallInt(v)),
4471            Err(_) => {
4472                return Err(EngineError::Eval(EvalError::TypeMismatch {
4473                    detail: "smallint out of range".into(),
4474                }));
4475            }
4476        },
4477        #[allow(clippy::cast_precision_loss)]
4478        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
4479        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
4480            i128::from(n),
4481            precision,
4482            scale,
4483            col_name,
4484        )?),
4485        (Value::Float(x), DataType::Numeric { precision, scale }) => {
4486            // Unconstrained `numeric` (precision 0 is the sentinel —
4487            // numeric(0,0) is invalid in PG) keeps the value's
4488            // natural scale instead of truncating to 0 decimals.
4489            // Route the float through its shortest round-trip decimal
4490            // text so `3.14::numeric` stays 3.14, not 3.
4491            if precision == 0 && scale == 0 && x.is_finite() {
4492                if let Some((mantissa, src_scale)) = parse_numeric_text(&alloc::format!("{x}")) {
4493                    Some(Value::Numeric {
4494                        scaled: mantissa,
4495                        scale: src_scale,
4496                        kind: spg_storage::NumericKind::Finite,
4497                    })
4498                } else {
4499                    Some(numeric_from_float(x, precision, scale, col_name)?)
4500                }
4501            } else {
4502                Some(numeric_from_float(x, precision, scale, col_name)?)
4503            }
4504        }
4505        // v7.39 (read01 round 110) — REAL (float4) → NUMERIC. Mirrors the
4506        // Float arm above; `real::numeric` used to have no arm at all, so the
4507        // value stayed a REAL and the column check rejected it. Format the f32
4508        // via its OWN shortest round-trip decimal (not through f64) so
4509        // `0.1::real::numeric` matches PG's float4 text.
4510        (Value::Real(x), DataType::Numeric { precision, scale }) => {
4511            if precision == 0 && scale == 0 && x.is_finite() {
4512                // v7.39 (round 662) — SIX significant digits, PG's `FLT_DIG`.
4513                // `format!("{x}")` is Rust's shortest round-trip, up to nine
4514                // digits for f32 — right for `real::text`, wrong here.
4515                // `real::numeric` is a different rule and PG measurably takes
4516                // the shorter one: `12345.678::real::numeric` is `12345.7`,
4517                // `1.23456789::real::numeric` is `1.23457`,
4518                // `123456789::real::numeric` is `123457000`. SPG answered
4519                // `12345.678`, `1.2345679`, `123456790` — more digits than a
4520                // float4 carries, presented as if it did.
4521                //
4522                // Found while adding `to_char(real, …)`: PG routes that
4523                // through numeric, not float8, so the missing overload was the
4524                // symptom and this cast was the cause.
4525                let six = alloc::format!("{:.5e}", x);
4526                let six: f64 = six.parse().unwrap_or_else(|_| f64::from(x));
4527                if let Some((mantissa, src_scale)) = parse_numeric_text(&alloc::format!("{six}")) {
4528                    Some(Value::Numeric {
4529                        scaled: mantissa,
4530                        scale: src_scale,
4531                        kind: spg_storage::NumericKind::Finite,
4532                    })
4533                } else {
4534                    Some(numeric_from_float(
4535                        f64::from(x),
4536                        precision,
4537                        scale,
4538                        col_name,
4539                    )?)
4540                }
4541            } else {
4542                Some(numeric_from_float(
4543                    f64::from(x),
4544                    precision,
4545                    scale,
4546                    col_name,
4547                )?)
4548            }
4549        }
4550        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
4551        // canonical decimal text (`"-1234.56"` / `"42"` /
4552        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
4553        // to the column's declared scale. Required for prepared
4554        // binds: `value_to_literal` flattens a Value::Numeric
4555        // into a TEXT literal because Literal carries no native
4556        // Numeric variant, so the placeholder substitution path
4557        // reaches coerce_value as Text → Numeric. Without this
4558        // arm the round-trip surfaces a TypeMismatch even though
4559        // the cell already left the engine as a valid Numeric.
4560        (Value::Text(s), DataType::Numeric { precision, scale }) => {
4561            // v7.38 (read01, T6) — PG's NUMERIC specials (`'NaN'`, `'Infinity'`,
4562            // `'-Infinity'`) parse before the ordinary decimal path.
4563            if let Some(kind) = crate::numeric::parse_numeric_special(&s) {
4564                return Ok(Value::numeric_special(kind));
4565            }
4566            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
4567                // v7.39 (read01 numeric.c) — PG's numeric input accepts
4568                // scientific notation ('1e300'::numeric): expand the exponent
4569                // and re-enter this arm with the plain form (which no longer
4570                // contains an 'e', so this recurses at most once).
4571                match spg_sql::parser::expand_scientific_literal(&s) {
4572                    spg_sql::parser::SciExpanded::Expanded(plain) => {
4573                        return coerce_value(
4574                            Value::Text(plain.into()),
4575                            DataType::Numeric { precision, scale },
4576                            col_name,
4577                            position,
4578                        );
4579                    }
4580                    spg_sql::parser::SciExpanded::Overflow => {
4581                        return Err(EngineError::Eval(EvalError::TypeMismatch {
4582                            detail: "value overflows numeric format".into(),
4583                        }));
4584                    }
4585                    spg_sql::parser::SciExpanded::NotScientific => {}
4586                }
4587                // A plain decimal whose mantissa overflows i128 is still a
4588                // valid unconstrained NUMERIC — keep it exact as NumericBig.
4589                if precision == 0 && scale == 0 {
4590                    if let Some(b) = spg_storage::bignum::BigNumeric::from_decimal_str(&s) {
4591                        return Ok(Value::NumericBig(alloc::boxed::Box::new(b)));
4592                    }
4593                }
4594                return Err(EngineError::Eval(EvalError::TypeMismatch {
4595                    detail: alloc::format!("invalid input syntax for type numeric: \"{s}\""),
4596                }));
4597            };
4598            // Unconstrained `numeric` keeps the parsed scale as-is.
4599            if precision == 0 && scale == 0 {
4600                Some(Value::Numeric {
4601                    scaled: mantissa,
4602                    scale: src_scale,
4603                    kind: spg_storage::NumericKind::Finite,
4604                })
4605            } else {
4606                Some(numeric_rescale(
4607                    mantissa, src_scale, precision, scale, col_name,
4608                )?)
4609            }
4610        }
4611        // Text → DATE / TIMESTAMP: parse canonical text forms.
4612        (Value::Text(s), DataType::Date) => {
4613            // PG truncates a full timestamp string on the way into a
4614            // DATE column (verified vs live PG18.4: INSERT
4615            // '2020-01-01 12:00:00' into a date column stores
4616            // 2020-01-01). Try the plain date parser first, then fall
4617            // back to the timestamp parser (validates the time) floored
4618            // to the day — mirroring the ::date cast path.
4619            let d = eval::parse_date_literal(&s)
4620                .or_else(|| {
4621                    eval::parse_timestamp_literal(&s)
4622                        .and_then(|t| i32::try_from(t.div_euclid(86_400_000_000)).ok())
4623                })
4624                .ok_or_else(|| datetime_parse_error("date", &s))?;
4625            Some(Value::Date(d))
4626        }
4627        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
4628        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
4629        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
4630        // form to the column's numeric / bool type at DEFAULT-
4631        // installation time so the storage check sees a typed
4632        // value. Parse failures fall through to TypeMismatch.
4633        // PG trims surrounding whitespace on numeric text input, so
4634        // `'  256  '::int2` / `'  3.14  '::float8` (both of which route
4635        // through this generic coerce path, unlike `::int` / `::float`
4636        // that trim in the CAST helper) parse rather than error.
4637        // v7.39 (read01 round 90) — a text value that fails to parse as the
4638        // target numeric type is PG's 22P02 `invalid input syntax for type
4639        // <T>: "<value>"`, not SPG's generic "type mismatch in column …". The
4640        // Numeric arm above already worded it this way; these matched it now.
4641        (Value::Text(s), DataType::SmallInt) => Some(Value::SmallInt(
4642            parse_pg_int(&s)
4643                .and_then(|n| i16::try_from(n).ok())
4644                .ok_or_else(|| invalid_input_syntax("smallint", &s))?,
4645        )),
4646        (Value::Text(s), DataType::Int) => Some(Value::Int(
4647            parse_pg_int(&s)
4648                .and_then(|n| i32::try_from(n).ok())
4649                .ok_or_else(|| invalid_input_syntax("integer", &s))?,
4650        )),
4651        (Value::Text(s), DataType::BigInt) => Some(Value::BigInt(
4652            parse_pg_int(&s).ok_or_else(|| invalid_input_syntax("bigint", &s))?,
4653        )),
4654        // v7.39 (round 640) — `INSERT INTO t(x) VALUES ('11')` into an
4655        // `xid` column, which is how PG takes one: the literal is
4656        // unknown-typed and the column's input function reads it. An
4657        // INTEGER in the same place is refused by both engines — PG
4658        // has no int-to-xid cast at all, measured.
4659        (Value::Text(s), DataType::Xid) => Some(Value::Xid(
4660            s.parse::<u32>()
4661                .map_err(|_| invalid_input_syntax("xid", &s))?,
4662        )),
4663        (Value::Xid(x), DataType::Xid) => Some(Value::Xid(x)),
4664        (Value::Text(s), DataType::Xid8) => Some(Value::BigInt(
4665            parse_pg_int(&s).ok_or_else(|| invalid_input_syntax("xid8", &s))?,
4666        )),
4667        // `'16'::xid8` evaluates to a BigInt — xid8 has a declared-type
4668        // identity but no value of its own, the way `xid` has
4669        // `Value::Xid`. The consequence is that SPG accepts a bigint
4670        // where PG refuses one ("column is of type xid8 but expression
4671        // is of type bigint"); closing that needs a `Value::Xid8`, which
4672        // is its own unit of work.
4673        (Value::BigInt(n), DataType::Xid8) => Some(Value::BigInt(n)),
4674        // v7.39 (round 667) — assigning into an OID column. PG takes an
4675        // integer here (and, measured, refuses the same integer for an xid
4676        // column); the range and wrap rules are the cast's, shared.
4677        (ref other, DataType::Oid) => coerce_to_oid(other)?,
4678        (Value::Text(s), DataType::Float) => {
4679            // v7.39 (round 270) — a numeric-looking text outside the
4680            // double range is "out of range", not "invalid input
4681            // syntax"; PG quotes the source either way.
4682            Some(Value::Float(
4683                parse_float8(&s).ok_or_else(|| float_text_error(&s, "double precision"))?,
4684            ))
4685        }
4686        // v7.38 (read01, T-float4) — coerce to REAL narrows to f32.
4687        (Value::Int(n), DataType::Real) => Some(Value::Real(n as f32)),
4688        (Value::SmallInt(n), DataType::Real) => Some(Value::Real(f32::from(n))),
4689        (Value::BigInt(n), DataType::Real) => Some(Value::Real(n as f32)),
4690        (Value::Float(x), DataType::Real) => {
4691            // v7.39 (round 269) — narrowing a finite f64 past the f32
4692            // range overflows; PG words this one "value out of range:
4693            // overflow" (it has no source text to quote).
4694            let narrowed = x as f32;
4695            if narrowed.is_infinite() && x.is_finite() {
4696                return Err(EngineError::Eval(EvalError::TypeMismatch {
4697                    detail: "value out of range: overflow".into(),
4698                }));
4699            }
4700            // v7.39 (round 270) — PG names the other end separately.
4701            if narrowed == 0.0 && x != 0.0 {
4702                return Err(EngineError::Eval(EvalError::TypeMismatch {
4703                    detail: "value out of range: underflow".into(),
4704                }));
4705            }
4706            Some(Value::Real(narrowed))
4707        }
4708        (
4709            Value::Numeric {
4710                scaled,
4711                scale,
4712                kind,
4713            },
4714            DataType::Real,
4715        ) => Some(Value::Real(match kind {
4716            spg_storage::NumericKind::NaN => f32::NAN,
4717            spg_storage::NumericKind::PosInf => f32::INFINITY,
4718            spg_storage::NumericKind::NegInf => f32::NEG_INFINITY,
4719            spg_storage::NumericKind::Finite => {
4720                let mut div = 1.0f64;
4721                for _ in 0..scale {
4722                    div *= 10.0;
4723                }
4724                let x = (scaled as f64 / div) as f32;
4725                // v7.39 (round 270) — same underflow rule at real's
4726                // (much nearer) bottom end.
4727                if x == 0.0 && scaled != 0 {
4728                    return Err(real_out_of_range(&crate::eval::format_numeric(
4729                        scaled, scale,
4730                    )));
4731                }
4732                x
4733            }
4734        })),
4735        (Value::Real(x), DataType::Float) => Some(Value::Float(f64::from(x))),
4736        // v7.39 (round 269) — overflowing the f32 range is an ERROR, not
4737        // an infinity. `parse::<f32>()` reports "1e40" as inf and this
4738        // used to hand that back, so a value PG rejects arrived as
4739        // Infinity and every later comparison against it was wrong. An
4740        // explicitly written infinity still passes; the test is whether
4741        // the SOURCE said infinity, not whether the result is one.
4742        (Value::Text(s), DataType::Real) => {
4743            let t = s.trim();
4744            let x = t
4745                .parse::<f32>()
4746                .ok()
4747                .ok_or_else(|| invalid_input_syntax("real", &s))?;
4748            if x.is_infinite() && !text_is_explicit_infinity(t) {
4749                return Err(real_out_of_range(t));
4750            }
4751            // v7.39 (round 270) — the other end: a nonzero source that
4752            // underflows to zero is an error too, not a silent 0.
4753            if x == 0.0 && float_text_is_nonzero(t) {
4754                return Err(real_out_of_range(t));
4755            }
4756            Some(Value::Real(x))
4757        }
4758        // PG boolin accepts any unambiguous prefix of true/false/yes/no,
4759        // plus on/off/1/0, case-insensitively with surrounding whitespace
4760        // trimmed. `o` alone is ambiguous (on vs off) → error.
4761        (Value::Text(s), DataType::Bool) => match s.trim().to_ascii_lowercase().as_str() {
4762            "0" | "f" | "fa" | "fal" | "fals" | "false" | "n" | "no" | "of" | "off" => {
4763                Some(Value::Bool(false))
4764            }
4765            "1" | "t" | "tr" | "tru" | "true" | "y" | "ye" | "yes" | "on" => {
4766                Some(Value::Bool(true))
4767            }
4768            _ => return Err(invalid_input_syntax("boolean", &s)),
4769        },
4770        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
4771        // classifies as DataType::Bool) is the storage shape every
4772        // mysqldump-restored boolean column lands in. mysqldump emits
4773        // the values as integer `0` / `1` literals, so int → bool
4774        // coerce on INSERT is required for a 0-change cutover. MySQL's
4775        // rule is "any non-zero is truthy"; we follow that for all
4776        // signed int widths so the same coerce path serves an
4777        // explicit `BOOLEAN` column too.
4778        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
4779        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
4780        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
4781        // v7.38.8 — text reaching a json/jsonb column is validated, the
4782        // way PG validates at its own input boundary.
4783        //
4784        // It was not, and the comment here said so: "no structural
4785        // validation — the responsibility for valid JSON lies with the
4786        // producer". The jsonb arm went further and swallowed the parse
4787        // error, storing the raw text when canonicalisation failed. So
4788        // `INSERT INTO t VALUES ('{bad')` into a jsonb column was
4789        // accepted (PG18: `invalid input syntax for type json`), and
4790        // every later read of that row raised instead — including, in
4791        // v7.38.7, one on the checkpoint thread, which is the worst
4792        // place for it because writes keep being acknowledged while
4793        // nothing reaches disk.
4794        //
4795        // Rejecting here is also what lets the accessors trust a
4796        // `Value::Json`: with the column boundary enforced, a value
4797        // that came out of storage IS valid, and `->>` no longer has
4798        // to parse the whole document per row to find that out.
4799        // Text → json/jsonb is handled before this match, so that a
4800        // document that will not parse reports PG's own message
4801        // instead of a type mismatch that misnames the problem.
4802        (Value::Json(s), DataType::Text) => Some(Value::text(s)),
4803        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
4804        // both JSON and JSONB on-disk as `Value::json(String)` —
4805        // they share the underlying text payload. The cast
4806        // `'<text>'::jsonb` produces a Value::Json that needs to
4807        // satisfy a DataType::Jsonb column. Identity coerce in
4808        // both directions so JSON ↔ JSONB assignments work at all
4809        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
4810        (Value::Json(s), DataType::Json) => Some(Value::json(s)),
4811        (Value::Json(s), DataType::Jsonb) => Some(Value::json(
4812            crate::json::canonicalize_jsonb(s.as_ref()).unwrap_or_else(|_| s.into_owned()),
4813        )),
4814        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
4815        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
4816        //   - Escape: `Hello\\000world`  (backslash + octal triples)
4817        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
4818        // Errors surface as TypeMismatch so the operator gets a
4819        // clear "this literal isn't a bytea literal" hint.
4820        (Value::Text(s), DataType::Bytes) => {
4821            let bytes = decode_bytea_literal(&s)
4822                .map_err(|e| EngineError::Eval(EvalError::TypeMismatch { detail: e }))?;
4823            Some(Value::bytes(bytes))
4824        }
4825        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
4826        // output (lowercase, `\x` prefix). Important when a
4827        // SELECT pulls a bytea cell through a Text column path.
4828        (Value::Bytes(b), DataType::Text) => Some(Value::text(encode_bytea_hex(&b))),
4829        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
4830        // unhyphenated, uppercase, and `{...}`-braced forms; we
4831        // funnel all four through `spg_storage::parse_uuid_str`.
4832        // A malformed literal surfaces as a SQL TypeMismatch
4833        // rather than silently inserting garbage — `0-change
4834        // cutover` requires that an app inserting bad UUID text
4835        // sees the same hard error PG would raise.
4836        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
4837            Some(b) => Some(Value::Uuid(b)),
4838            None => {
4839                return Err(EngineError::Eval(EvalError::TypeMismatch {
4840                    detail: alloc::format!("invalid input syntax for type uuid: {s:?}"),
4841                }));
4842            }
4843        },
4844        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
4845        // Surfaces when a SELECT plucks a uuid cell through a
4846        // Text column path (e.g. INSERT INTO log SELECT id::text
4847        // FROM other_table).
4848        (Value::Uuid(b), DataType::Text) => Some(Value::text(spg_storage::format_uuid(&b))),
4849        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
4850        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
4851        // Out-of-range hour/min/sec is a hard SQL error (no
4852        // silent truncation — same 0-change-cutover discipline
4853        // we apply to UUID).
4854        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
4855            Some(us) => Some(Value::Time(us)),
4856            None => {
4857                // v7.39 (round 764, F31 tranche 3 #81) — PG splits the
4858                // refusals: a time-SHAPED literal with an impossible
4859                // component (`25:00:00`, `10:61:00`) is "date/time
4860                // field value out of range" (22008-family), only junk
4861                // is "invalid input syntax" (PG18-measured).
4862                let time_shaped = {
4863                    let core = s.trim().split('.').next().unwrap_or("");
4864                    !core.is_empty()
4865                        && core.split(':').count() >= 2
4866                        && core
4867                            .split(':')
4868                            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
4869                };
4870                let detail = if time_shaped {
4871                    alloc::format!("date/time field value out of range: {s:?}")
4872                } else {
4873                    alloc::format!("invalid input syntax for type time: {s:?}")
4874                };
4875                return Err(EngineError::Eval(EvalError::TypeMismatch { detail }));
4876            }
4877        },
4878        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
4879        (Value::Time(us), DataType::Text) => Some(Value::text(eval::format_time(us))),
4880        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
4881        // check enforces the MySQL canonical 1901..=2155 + 0
4882        // sentinel; out-of-range is a hard SQL error (no silent
4883        // truncation, mirrors P0-32 / P0-25 discipline).
4884        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
4885        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
4886        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
4887        // Text → YEAR. Accepts the 4-digit decimal form only;
4888        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
4889        // 5.7 and is out of scope for v7.17.0.
4890        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
4891            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
4892            Err(_) => {
4893                return Err(EngineError::Eval(EvalError::TypeMismatch {
4894                    detail: alloc::format!("invalid input syntax for type year: {s:?}"),
4895                }));
4896            }
4897        },
4898        // YEAR → Text 4-digit zero-padded.
4899        (Value::Year(y), DataType::Text) => Some(Value::text(alloc::format!("{y:04}"))),
4900        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ.
4901        // v7.39 (round 761, F31 tranche 2 #59) — an offset-less
4902        // literal is accepted at the session offset, PG18-measured
4903        // (`INSERT '07:08:09'` into a TIMETZ column reads back
4904        // `07:08:09+00` in a UTC session). The old "mandatory signed
4905        // offset" rule refused what PG accepts; offset 0 is the same
4906        // session-zero assumption the time→timetz cast below carries.
4907        // v7.39 (round 634) — a time or a timestamp reaching `::TIMETZ`.
4908        // PG registers time -> timetz as IMPLICIT and timestamptz -> timetz
4909        // as an assignment cast; SPG answered "cannot cast time without
4910        // time zone to USER-DEFINED", the target having fallen through to
4911        // the user-type lookup. The session offset is zero here, which is
4912        // what SPG's timetz values already carry.
4913        (Value::Time(t), DataType::TimeTz) => Some(Value::TimeTz {
4914            us: t,
4915            offset_secs: 0,
4916        }),
4917        (Value::Timestamp(t), DataType::TimeTz) => Some(Value::TimeTz {
4918            us: t.rem_euclid(86_400_000_000),
4919            offset_secs: 0,
4920        }),
4921        (Value::Text(s), DataType::TimeTz) => {
4922            match parse_timetz_str(&s).or_else(|| parse_time_str(s.trim()).map(|us| (us, 0))) {
4923                Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
4924                None => {
4925                    return Err(EngineError::Eval(EvalError::TypeMismatch {
4926                        detail: alloc::format!(
4927                            "invalid input syntax for type time with time zone: \
4928                         {s:?}"
4929                        ),
4930                    }));
4931                }
4932            }
4933        }
4934        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
4935        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
4936            Some(Value::text(eval::format_timetz(us, offset_secs)))
4937        }
4938        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
4939        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
4940        // arrive via the Int/BigInt/Float/Numeric arms below.
4941        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
4942            Some(c) => Some(Value::Money(c)),
4943            None => {
4944                return Err(EngineError::Eval(EvalError::TypeMismatch {
4945                    detail: alloc::format!("invalid input syntax for type money: {s:?}"),
4946                }));
4947            }
4948        },
4949        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
4950        // Bare numeric literal is interpreted as a major-unit
4951        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
4952        (Value::SmallInt(n), DataType::Money) => {
4953            Some(Value::Money(i64::from(n).saturating_mul(100)))
4954        }
4955        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
4956        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
4957        (Value::Float(x), DataType::Money) => {
4958            // Round half-away-from-zero to cents (no_std — no
4959            // `f64::round`, so hand-roll via biased truncation).
4960            let scaled = x * 100.0;
4961            let cents = if scaled >= 0.0 {
4962                (scaled + 0.5) as i64
4963            } else {
4964                (scaled - 0.5) as i64
4965            };
4966            Some(Value::Money(cents))
4967        }
4968        (Value::Numeric { scaled, scale, .. }, DataType::Money) => {
4969            // Convert exact decimal to cents (scale 2). If scale > 2,
4970            // round half-away-from-zero. If scale < 2, multiply up.
4971            let cents = if scale == 2 {
4972                scaled
4973            } else if scale < 2 {
4974                let mult = 10_i128.pow(u32::from(2 - scale));
4975                scaled.saturating_mul(mult)
4976            } else {
4977                let div = 10_i128.pow(u32::from(scale - 2));
4978                let half = div / 2;
4979                let bias = if scaled >= 0 { half } else { -half };
4980                (scaled + bias) / div
4981            };
4982            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
4983        }
4984        // MONEY → Text canonical `$N,NNN.CC`.
4985        (Value::Money(c), DataType::Text) => Some(Value::text(eval::format_money(c))),
4986        // MONEY → NUMERIC: integer cents become a scale-2 decimal (dollars).
4987        (Value::Money(c), DataType::Numeric { .. }) => Some(Value::Numeric {
4988            scaled: i128::from(c),
4989            scale: 2,
4990            kind: spg_storage::NumericKind::Finite,
4991        }),
4992        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
4993        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
4994        // `'(a,b)'`, with empty lower or upper for unbounded.
4995        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
4996            Ok(v) => Some(v),
4997            // v7.39 (read01 rangetypes.c) — PG's two distinct rejections.
4998            Err(RangeParseError::Misordered) => {
4999                return Err(EngineError::Eval(EvalError::TypeMismatch {
5000                    detail: alloc::string::String::from(
5001                        "range lower bound must be less than or equal to range upper bound",
5002                    ),
5003                }));
5004            }
5005            Err(RangeParseError::Malformed) => {
5006                return Err(EngineError::Eval(EvalError::TypeMismatch {
5007                    detail: alloc::format!("malformed range literal: \"{s}\""),
5008                }));
5009            }
5010            Err(RangeParseError::BadElement(bad)) => {
5011                return Err(EngineError::Eval(EvalError::TypeMismatch {
5012                    detail: alloc::format!(
5013                        "invalid input syntax for type {}: \"{bad}\"",
5014                        range_element_type_name(kind)
5015                    ),
5016                }));
5017            }
5018        },
5019        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
5020        (v @ Value::Range { .. }, DataType::Text) => Some(Value::text(format_range_str(&v))),
5021        // v7.37.5 ζ-A — Text → network / bit / xml / "char" / money[].
5022        (Value::Text(s), DataType::Inet) => match parse_inet_text(&s) {
5023            Some((family, bits, addr)) => Some(Value::Inet { family, bits, addr }),
5024            None => {
5025                // v7.39 (round 262) — PG's wording: the lowercase type
5026                // name and no column suffix (the cidr arm below already
5027                // had it right).
5028                return Err(EngineError::Eval(EvalError::TypeMismatch {
5029                    detail: alloc::format!("invalid input syntax for type inet: {s:?}"),
5030                }));
5031            }
5032        },
5033        // v7.39 (round 262) — the inet <-> cidr casts, probed live:
5034        // `inet::cidr` keeps the mask length (defaulting to the family's
5035        // full width) and ZEROES the host bits, so `192.168.1.5/24`
5036        // becomes `192.168.1.0/24`; `cidr::inet` passes through
5037        // unchanged. Neither existed, so both raised a storage type
5038        // mismatch on perfectly ordinary SQL.
5039        (Value::Inet { family, bits, addr }, DataType::Cidr) => {
5040            let full = if family == 6 { 128 } else { 32 };
5041            let bits = if bits > full { full } else { bits };
5042            let mut masked = addr;
5043            for i in 0..16usize {
5044                let bit_start = i * 8;
5045                if bit_start >= usize::from(bits) {
5046                    masked[i] = 0;
5047                } else if bit_start + 8 > usize::from(bits) {
5048                    let keep = usize::from(bits) - bit_start;
5049                    masked[i] &= 0xffu8 << (8 - keep);
5050                }
5051            }
5052            Some(Value::Cidr {
5053                family,
5054                bits,
5055                addr: masked,
5056            })
5057        }
5058        (Value::Cidr { family, bits, addr }, DataType::Inet) => {
5059            Some(Value::Inet { family, bits, addr })
5060        }
5061        (Value::Text(s), DataType::Cidr) => match parse_cidr_text(&s) {
5062            Ok(Some((family, bits, addr))) => Some(Value::Cidr { family, bits, addr }),
5063            Err(()) => {
5064                return Err(EngineError::Eval(EvalError::TypeMismatch {
5065                    detail: alloc::format!(
5066                        "invalid cidr value: {s:?} DETAIL: Value has bits set to right of mask."
5067                    ),
5068                }));
5069            }
5070            Ok(None) => {
5071                return Err(EngineError::Eval(EvalError::TypeMismatch {
5072                    detail: alloc::format!("invalid input syntax for type cidr: {s:?}"),
5073                }));
5074            }
5075        },
5076        // INSERT / assignment of a text literal into an INTERVAL column
5077        // parses it, matching the `::interval` cast (mirrors macaddr/inet).
5078        (Value::Text(s), DataType::Interval) => match spg_sql::parser::parse_interval_text(&s) {
5079            Some((months, days, micros)) => Some(Value::Interval {
5080                months,
5081                days,
5082                micros,
5083                kind: spg_storage::IntervalKind::from_fields(months, days, micros),
5084            }),
5085            None => {
5086                return Err(EngineError::Eval(EvalError::TypeMismatch {
5087                    detail: alloc::format!("invalid input syntax for type interval: {s:?}"),
5088                }));
5089            }
5090        },
5091        (Value::Text(s), DataType::Macaddr) => match parse_macaddr_text(&s) {
5092            Some(m) => Some(Value::Macaddr(m)),
5093            None => {
5094                return Err(EngineError::Eval(EvalError::TypeMismatch {
5095                    detail: alloc::format!("invalid input syntax for type macaddr: {s:?}"),
5096                }));
5097            }
5098        },
5099        // v7.39 (read01 pg_lsn.c) — `XX/XX` hex pair, each half <= u32.
5100        (Value::Text(s), DataType::PgLsn) => match parse_pg_lsn_text(&s) {
5101            Some(l) => Some(Value::PgLsn(l)),
5102            None => {
5103                return Err(EngineError::Eval(EvalError::TypeMismatch {
5104                    detail: alloc::format!("invalid input syntax for type pg_lsn: \"{s}\""),
5105                }));
5106            }
5107        },
5108        (Value::Text(s), DataType::Macaddr8) => match parse_macaddr8_text(&s) {
5109            Some(m) => Some(Value::Macaddr8(m)),
5110            None => {
5111                return Err(EngineError::Eval(EvalError::TypeMismatch {
5112                    detail: alloc::format!("invalid input syntax for type macaddr8: {s:?}"),
5113                }));
5114            }
5115        },
5116        // v7.37.5 ship triage — `Value::BitString` self-reports as
5117        // `DataType::BitVarying(0)` (see `Value::data_type`), so an
5118        // INSERT into a `BIT` column triggered a spurious type
5119        // mismatch. Accept BitString into either.
5120        //
5121        // v7.39 (round 281) — and enforce the declared length, which
5122        // used to be parsed and dropped so `bit(3)` took a five-bit
5123        // string. PG's two types differ: BIT is FIXED (a shorter value
5124        // is an error too) while BIT VARYING is a maximum. An explicit
5125        // CAST still pads or truncates — the same assignment-enforces /
5126        // cast-adjusts split the varchar arms below already model.
5127        (Value::BitString { nbits, bytes }, DataType::Bit(n)) => {
5128            // A bare `bit` is `bit(1)` in PG.
5129            let want = if n == 0 { 1 } else { n };
5130            if nbits != want {
5131                return Err(EngineError::Unsupported(alloc::format!(
5132                    "bit string length {nbits} does not match type bit({want})"
5133                )));
5134            }
5135            Some(Value::BitString { nbits, bytes })
5136        }
5137        (Value::BitString { nbits, bytes }, DataType::BitVarying(n)) => {
5138            if n != 0 && nbits > n {
5139                return Err(EngineError::Unsupported(alloc::format!(
5140                    "bit string too long for type bit varying({n})"
5141                )));
5142            }
5143            Some(Value::BitString { nbits, bytes })
5144        }
5145        (Value::Text(s), bit_ty @ (DataType::Bit(_) | DataType::BitVarying(_))) => {
5146            match parse_bit_string_text(&s) {
5147                Some((nbits, bytes)) => {
5148                    // v7.39 (round 325, V57) — the DECLARED width applies to a
5149                    // string literal too. It was checked only on the
5150                    // `B'…'` bit-literal path, so `INSERT INTO t(b)
5151                    // VALUES ('10')` into a `BIT(3)` column was accepted and
5152                    // stored two bits wide — a column that promises a fixed
5153                    // width silently holding another one. PG 18.4:
5154                    // `bit string length 2 does not match type bit(3)`, and
5155                    // `bit string too long for type bit varying(3)` past a
5156                    // varying cap.
5157                    match bit_ty {
5158                        // A bare `bit` is `bit(1)` in PG, as the arm above.
5159                        DataType::Bit(n) => {
5160                            let want = if n == 0 { 1 } else { n };
5161                            if nbits != want {
5162                                return Err(EngineError::Unsupported(alloc::format!(
5163                                    "bit string length {nbits} does not match type bit({want})"
5164                                )));
5165                            }
5166                        }
5167                        DataType::BitVarying(n) if n != 0 && nbits > n => {
5168                            return Err(EngineError::Unsupported(alloc::format!(
5169                                "bit string too long for type bit varying({n})"
5170                            )));
5171                        }
5172                        _ => {}
5173                    }
5174                    Some(Value::bit_string(nbits, bytes))
5175                }
5176                None => {
5177                    // v7.39 (read01 varbit.c) — PG names the first bad digit.
5178                    let bad = s.chars().find(|c| *c != '0' && *c != '1');
5179                    return Err(EngineError::Eval(EvalError::TypeMismatch {
5180                        detail: match bad {
5181                            Some(c) => {
5182                                alloc::format!("\"{c}\" is not a valid binary digit")
5183                            }
5184                            None => alloc::format!("invalid input syntax for BIT: {s:?}"),
5185                        },
5186                    }));
5187                }
5188            }
5189        }
5190        (Value::Text(s), DataType::Xml) => {
5191            // v7.38 (read01 P6.38) — `::xml` (PG's CONTENT mode) requires the
5192            // text to be well-formed: element tags must be balanced and
5193            // properly nested. Plain text, multiple top-level elements,
5194            // comments/PIs/CDATA and self-closing tags are all fine.
5195            if !xml_content_is_well_formed(&s) {
5196                return Err(EngineError::Eval(EvalError::TypeMismatch {
5197                    detail: alloc::format!("invalid XML content: {s:?}"),
5198                }));
5199            }
5200            Some(Value::xml(s))
5201        }
5202        // v7.39 (round 634) — the bpchar forms of two casts the Text arms
5203        // above already have. `'ab'::CHAR(4)::"char"` answered "cannot cast
5204        // character to \"char\"" and `::XML` likewise, while the same value
5205        // as TEXT worked: the cast path never normalises a bpchar the way
5206        // the function dispatch does. PG answers `a` and `ab` — the text
5207        // form of a bpchar drops its padding.
5208        (Value::BpChar(s), DataType::Char1) => {
5209            Some(Value::Char1(s.as_bytes().first().copied().unwrap_or(0)))
5210        }
5211        (Value::BpChar(s), DataType::Xml) => {
5212            let stripped = s.trim_end_matches(' ');
5213            if !xml_content_is_well_formed(stripped) {
5214                return Err(EngineError::Eval(EvalError::TypeMismatch {
5215                    detail: alloc::format!("invalid XML content: {stripped:?}"),
5216                }));
5217            }
5218            Some(Value::xml(alloc::string::String::from(stripped)))
5219        }
5220        // v7.39 (round 634) — bytea to an integer reads the bytes
5221        // BIG-ENDIAN, all of them, and errors when the result does not fit.
5222        // Measured on PG: `'\x3132'` is 12594, a single `'\x31'` is 49, an
5223        // empty bytea is 0, and three bytes into a smallint is
5224        // "smallint out of range".
5225        (Value::Bytes(b), DataType::SmallInt | DataType::Int | DataType::BigInt) => {
5226            let mut acc: i128 = 0;
5227            for byte in b.iter() {
5228                acc = acc.saturating_mul(256).saturating_add(i128::from(*byte));
5229            }
5230            let (fits, made) = match expected {
5231                DataType::SmallInt => (
5232                    i16::try_from(acc).is_ok(),
5233                    i16::try_from(acc).map(Value::SmallInt).ok(),
5234                ),
5235                DataType::Int => (
5236                    i32::try_from(acc).is_ok(),
5237                    i32::try_from(acc).map(Value::Int).ok(),
5238                ),
5239                _ => (
5240                    i64::try_from(acc).is_ok(),
5241                    i64::try_from(acc).map(Value::BigInt).ok(),
5242                ),
5243            };
5244            if !fits {
5245                return Err(EngineError::Eval(EvalError::TypeMismatch {
5246                    detail: alloc::format!("{} out of range", pg_type_name_for_error(expected)),
5247                }));
5248            }
5249            made
5250        }
5251        // v7.39 (read01 char.c) — an integer coerces to "char" by its
5252        // low byte (65::"char" = 'A'; PG's i2char/int4char).
5253        (Value::Int(n), DataType::Char1) => Some(Value::Char1((n & 0xff) as u8)),
5254        (Value::SmallInt(n), DataType::Char1) => Some(Value::Char1((n & 0xff) as u8)),
5255        (Value::BigInt(n), DataType::Char1) => Some(Value::Char1((n & 0xff) as u8)),
5256        (Value::Text(s), DataType::Char1) => {
5257            // v7.39 (read01 utils/adt, char.c) — charin accepts the
5258            // `\ooo` octal form charout produces for high bytes
5259            // ('\101'::"char" = 'A'); otherwise the FIRST byte, with
5260            // any remainder silently discarded (PG's compatibility
5261            // provision); empty = 0x00.
5262            let bytes = s.as_bytes();
5263            if bytes.len() == 4
5264                && bytes[0] == b'\\'
5265                && bytes[1..].iter().all(|b| (b'0'..=b'7').contains(b))
5266            {
5267                let v = ((bytes[1] - b'0') << 6) | ((bytes[2] - b'0') << 3) | (bytes[3] - b'0');
5268                Some(Value::Char1(v))
5269            } else {
5270                let b = s.bytes().next().unwrap_or(0);
5271                Some(Value::Char1(b))
5272            }
5273        }
5274        // v7.37.5 ζ-A — inverse coerces.
5275        (Value::Inet { family, bits, addr }, DataType::Text) => {
5276            // v7.39 (read01 inet family) — PG's text(inet) ALWAYS carries
5277            // the /netmask (192.168.1.5 -> "192.168.1.5/32"), unlike the
5278            // display form which suppresses a full-length mask.
5279            let base = format_inet(family, bits, &addr);
5280            Some(Value::text(if base.contains('/') {
5281                base
5282            } else {
5283                alloc::format!("{base}/{bits}")
5284            }))
5285        }
5286        (Value::Cidr { family, bits, addr }, DataType::Text) => {
5287            Some(Value::text(format_inet(family, bits, &addr)))
5288        }
5289        (Value::Macaddr(m), DataType::Text) => Some(Value::text(format_macaddr(&m))),
5290        (Value::Macaddr8(m), DataType::Text) => Some(Value::text(format_macaddr8(&m))),
5291        (Value::PgLsn(l), DataType::Text) => Some(Value::text(format_pg_lsn(l))),
5292        // MACADDR → MACADDR8: PG widens EUI-48 to EUI-64 by inserting the
5293        // `ff:fe` marker in the middle (08:00:2b:01:02:03 → 08:00:2b:ff:fe:01:02:03).
5294        (Value::Macaddr(m), DataType::Macaddr8) => Some(Value::Macaddr8([
5295            m[0], m[1], m[2], 0xff, 0xfe, m[3], m[4], m[5],
5296        ])),
5297        (Value::BitString { nbits, bytes }, DataType::Text) => {
5298            Some(Value::text(format_bit_string(nbits, &bytes)))
5299        }
5300        // BIT → integer: MSB-first bit value (PG bit→int cast).
5301        #[allow(clippy::cast_possible_truncation)]
5302        (Value::BitString { nbits, bytes }, DataType::SmallInt) => {
5303            Some(Value::SmallInt(bit_string_to_i64(nbits, &bytes) as i16))
5304        }
5305        #[allow(clippy::cast_possible_truncation)]
5306        (Value::BitString { nbits, bytes }, DataType::Int) => {
5307            Some(Value::Int(bit_string_to_i64(nbits, &bytes) as i32))
5308        }
5309        (Value::BitString { nbits, bytes }, DataType::BigInt) => {
5310            Some(Value::BigInt(bit_string_to_i64(nbits, &bytes)))
5311        }
5312        (Value::Xml(s), DataType::Text) => Some(Value::text(s)),
5313        (Value::Char1(b), DataType::Text) => Some(Value::text((b as char).to_string())),
5314        // v7.37.5 ε — Text → geometry coerce. Each parser returns
5315        // None on malformed input; we surface a TypeMismatch with
5316        // the column name so the engine error is debuggable.
5317        (Value::Text(s), DataType::Point) => match parse_point(&s) {
5318            Some(p) => Some(Value::Point(p)),
5319            None => {
5320                return Err(EngineError::Eval(EvalError::TypeMismatch {
5321                    detail: alloc::format!("invalid input syntax for type point: {s:?}"),
5322                }));
5323            }
5324        },
5325        (Value::Text(s), DataType::Lseg) => match parse_lseg_text(&s) {
5326            Some((p1, p2)) => Some(Value::Lseg(p1, p2)),
5327            None => {
5328                return Err(EngineError::Eval(EvalError::TypeMismatch {
5329                    detail: alloc::format!("invalid input syntax for type lseg: {s:?}"),
5330                }));
5331            }
5332        },
5333        (Value::Text(s), DataType::PgBox) => match parse_box_text(&s) {
5334            Some((ur, ll)) => Some(Value::PgBox(ur, ll)),
5335            None => {
5336                return Err(EngineError::Eval(EvalError::TypeMismatch {
5337                    detail: alloc::format!("invalid input syntax for type box: {s:?}"),
5338                }));
5339            }
5340        },
5341        (Value::Text(s), DataType::Line) => match parse_line_text(&s) {
5342            Some((a, b, c)) => Some(Value::Line { a, b, c }),
5343            None => {
5344                // v7.39 (round 775, F31 J6) — the degenerate `{0,0,C}`
5345                // form gets PG's OWN sentence (measured), not the
5346                // generic syntax one.
5347                let zero_ab = s
5348                    .trim()
5349                    .strip_prefix('{')
5350                    .and_then(|x| x.strip_suffix('}'))
5351                    .map(|inner| inner.split(',').collect::<alloc::vec::Vec<_>>())
5352                    .is_some_and(|parts| {
5353                        parts.len() == 3
5354                            && parts[0].trim().parse::<f64>() == Ok(0.0)
5355                            && parts[1].trim().parse::<f64>() == Ok(0.0)
5356                            && parts[2].trim().parse::<f64>().is_ok()
5357                    });
5358                let detail = if zero_ab {
5359                    alloc::string::String::from(
5360                        "invalid line specification: A and B cannot both be zero",
5361                    )
5362                } else {
5363                    alloc::format!("invalid input syntax for type line: {s:?}")
5364                };
5365                return Err(EngineError::Eval(EvalError::TypeMismatch { detail }));
5366            }
5367        },
5368        (Value::Text(s), DataType::Circle) => match parse_circle_text(&s) {
5369            Some((center, radius)) => Some(Value::Circle { center, radius }),
5370            None => {
5371                return Err(EngineError::Eval(EvalError::TypeMismatch {
5372                    detail: alloc::format!("invalid input syntax for type circle: {s:?}"),
5373                }));
5374            }
5375        },
5376        (Value::Text(s), DataType::Path) => match parse_path_text(&s) {
5377            Some((points, closed)) => Some(Value::Path { points, closed }),
5378            None => {
5379                return Err(EngineError::Eval(EvalError::TypeMismatch {
5380                    detail: alloc::format!("invalid input syntax for type path: {s:?}"),
5381                }));
5382            }
5383        },
5384        // v7.39 (read01 geo_ops.c) — box_poly: a box converts to its
5385        // 4-corner polygon (low, (low.x, high.y), high, (high.x, low.y)).
5386        (Value::PgBox(a, b), DataType::Polygon) => {
5387            let (hx, hy) = (a.x.max(b.x), a.y.max(b.y));
5388            let (lx, ly) = (a.x.min(b.x), a.y.min(b.y));
5389            let p = |x: f64, y: f64| spg_storage::Point2D { x, y };
5390            Some(Value::Polygon(alloc::vec![
5391                p(lx, ly),
5392                p(lx, hy),
5393                p(hx, hy),
5394                p(hx, ly),
5395            ]))
5396        }
5397        (Value::Text(s), DataType::Polygon) => match parse_polygon_text(&s) {
5398            Some(points) => Some(Value::Polygon(points)),
5399            None => {
5400                return Err(EngineError::Eval(EvalError::TypeMismatch {
5401                    detail: alloc::format!("invalid input syntax for type polygon: {s:?}"),
5402                }));
5403            }
5404        },
5405        // v7.37.5 ε — geometry → Text canonical forms.
5406        (Value::Point(p), DataType::Text) => Some(Value::text(format_point(p))),
5407        (Value::Lseg(p1, p2), DataType::Text) => Some(Value::text(format_lseg(p1, p2))),
5408        (Value::PgBox(ur, ll), DataType::Text) => Some(Value::text(format_pg_box(ur, ll))),
5409        (Value::Line { a, b, c }, DataType::Text) => Some(Value::text(format_line(a, b, c))),
5410        (Value::Circle { center, radius }, DataType::Text) => {
5411            Some(Value::text(format_circle(center, radius)))
5412        }
5413        (Value::Path { points, closed }, DataType::Text) => {
5414            Some(Value::text(format_path(&points, closed)))
5415        }
5416        (Value::Polygon(points), DataType::Text) => Some(Value::text(format_polygon(&points))),
5417        // v7.37.5 δ — Text → Multirange. Accepts `{}` empty and
5418        // `{[a,b),[c,d),...}` comma-separated ranges; each
5419        // subrange parses with the parent kind.
5420        // v7.39 (round 256) — `range::<type>multirange`: PG casts a range
5421        // to the one-element multirange containing it (an empty range
5422        // gives the empty multirange).
5423        (ref rv @ Value::Range { kind: rk, .. }, DataType::Multirange(kind)) => {
5424            if rk != kind {
5425                return Err(EngineError::Eval(EvalError::TypeMismatch {
5426                    detail: alloc::format!(
5427                        "cannot cast type {} to {}",
5428                        DataType::Range(rk),
5429                        DataType::Multirange(kind)
5430                    ),
5431                }));
5432            }
5433            crate::eval::binop::range_as_multirange(rv)
5434        }
5435        (Value::Text(s), DataType::Multirange(kind)) => match parse_multirange_str(&s, kind) {
5436            // v7.39 (round 231) — a multirange is normalized whatever built
5437            // it. The constructor function already sorted / merged / dropped
5438            // empties; the text cast kept the literal's spans verbatim, so
5439            // `'{[1,3),[3,5)}'::int4multirange` printed back two adjacent
5440            // spans where PG prints the merged `{[1,5)}`.
5441            Some(ranges) => Some(Value::Multirange {
5442                kind,
5443                ranges: crate::eval::binop::normalize_multirange_spans(kind, &ranges),
5444            }),
5445            None => {
5446                return Err(EngineError::Eval(EvalError::TypeMismatch {
5447                    detail: alloc::format!("invalid input syntax for multirange type: {s:?}"),
5448                }));
5449            }
5450        },
5451        // Multirange → Text canonical form (`{[a,b),[c,d)}`).
5452        (Value::Multirange { ranges, .. }, DataType::Text) => {
5453            Some(Value::text(format_multirange(&ranges)))
5454        }
5455        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
5456        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
5457            Some(pairs) => Some(Value::Hstore(pairs)),
5458            None => {
5459                return Err(EngineError::Eval(EvalError::TypeMismatch {
5460                    detail: alloc::format!("invalid input syntax for type hstore: {s:?}"),
5461                }));
5462            }
5463        },
5464        // Hstore → Text canonical `"k"=>"v"` form.
5465        (Value::Hstore(pairs), DataType::Text) => Some(Value::text(format_hstore_str(&pairs))),
5466        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
5467        // external `'{{a,b},{c,d}}'` literal.
5468        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
5469            Ok(m) => Some(Value::IntArray2D(m)),
5470            Err(e) => {
5471                return Err(EngineError::Eval(EvalError::TypeMismatch {
5472                    detail: alloc::format!("invalid input syntax for INT[][]: {s:?}: {e}"),
5473                }));
5474            }
5475        },
5476        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
5477            Ok(m) => Some(Value::BigIntArray2D(m)),
5478            Err(e) => {
5479                return Err(EngineError::Eval(EvalError::TypeMismatch {
5480                    detail: alloc::format!("invalid input syntax for BIGINT[][]: {s:?}: {e}"),
5481                }));
5482            }
5483        },
5484        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
5485            Ok(m) => Some(Value::TextArray2D(m)),
5486            Err(e) => {
5487                return Err(EngineError::Eval(EvalError::TypeMismatch {
5488                    detail: alloc::format!("invalid input syntax for TEXT[][]: {s:?}: {e}"),
5489                }));
5490            }
5491        },
5492        // 2D arrays → Text canonical nested form.
5493        (Value::IntArray2D(rows), DataType::Text) => Some(Value::text(format_int_2d_text(&rows))),
5494        (Value::BigIntArray2D(rows), DataType::Text) => {
5495            Some(Value::text(format_bigint_2d_text(&rows)))
5496        }
5497        (Value::TextArray2D(rows), DataType::Text) => Some(Value::text(format_text_2d_text(&rows))),
5498        // v7.10.11 — Text → TEXT[]. Decode PG's external array
5499        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
5500        // is the literal `NULL`; everything else is a quoted or
5501        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
5502        (Value::Text(s), DataType::TextArray) => {
5503            // v7.39 (round 325, V57) — PG's wording (and the same message
5504            // the CAST path gives for the identical input; this one used to
5505            // name TEXT[] whatever the column's element type was).
5506            let arr = decode_text_array_literal(&s).map_err(|_| {
5507                EngineError::Eval(EvalError::TypeMismatch {
5508                    detail: malformed_array_literal(&s),
5509                })
5510            })?;
5511            Some(Value::TextArray(arr))
5512        }
5513        // v7.16.0 — Text → IntArray / BigIntArray for the
5514        // spg-sqlx Bind path. Decode the PG external form
5515        // `{1,2,3}` as a TEXT array first, then parse each
5516        // element as int. Same shape as the TextArray decode
5517        // above with an element-wise narrow.
5518        (Value::Text(s), DataType::IntArray) => {
5519            // v7.39 (round 325, V57) — PG's wording (and the same message
5520            // the CAST path gives for the identical input; this one used to
5521            // name TEXT[] whatever the column's element type was).
5522            let arr = decode_text_array_literal(&s).map_err(|_| {
5523                EngineError::Eval(EvalError::TypeMismatch {
5524                    detail: malformed_array_literal(&s),
5525                })
5526            })?;
5527            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
5528            for elem in arr {
5529                match elem {
5530                    None => out.push(None),
5531                    Some(t) => {
5532                        let n: i32 = t.parse().map_err(|_| {
5533                            EngineError::Eval(EvalError::TypeMismatch {
5534                                detail: alloc::format!(
5535                                    "invalid input syntax for type integer: {t:?}"
5536                                ),
5537                            })
5538                        })?;
5539                        out.push(Some(n));
5540                    }
5541                }
5542            }
5543            Some(Value::IntArray(out))
5544        }
5545        // v7.38 (read01) — the remaining Text → typed-array casts
5546        // (`'{1.5}'::numeric[]`, `'{t}'::bool[]`, `'{2020-01-01}'::date[]`, …),
5547        // which previously errored while `::int[]` / `::text[]` worked.
5548        (Value::Text(s), DataType::SmallIntArray) => Some(Value::SmallIntArray(
5549            decode_array_elems(&s, DataType::SmallInt, col_name, position)?
5550                .into_iter()
5551                .map(|o| match o {
5552                    Some(Value::SmallInt(n)) => Some(n),
5553                    _ => None,
5554                })
5555                .collect(),
5556        )),
5557        (Value::Text(s), DataType::BoolArray) => {
5558            // v7.39 (read01 round 92) — a 2-D bool literal `{{t,f},{f,t}}`
5559            // becomes a BoolArray2D (the ::int[]/::text[] cast path learned this
5560            // separately; the typed-array coerce path routes here). 1-D stays a
5561            // BoolArray.
5562            if let Some(rows) = crate::eval::values::split_2d_rows(&s) {
5563                let mut row_vals: Vec<Value<'static>> = Vec::with_capacity(rows.len());
5564                for r in &rows {
5565                    let bools: Vec<Option<bool>> =
5566                        decode_array_elems(r, DataType::Bool, col_name, position)?
5567                            .into_iter()
5568                            .map(|o| match o {
5569                                Some(Value::Bool(b)) => Some(b),
5570                                _ => None,
5571                            })
5572                            .collect();
5573                    row_vals.push(Value::BoolArray(bools));
5574                }
5575                return crate::eval::values::build_2d_from_rows(&row_vals).ok_or_else(|| {
5576                    EngineError::Eval(EvalError::TypeMismatch {
5577                        detail: malformed_array_literal(&s),
5578                    })
5579                });
5580            }
5581            Some(Value::BoolArray(
5582                decode_array_elems(&s, DataType::Bool, col_name, position)?
5583                    .into_iter()
5584                    .map(|o| match o {
5585                        Some(Value::Bool(b)) => Some(b),
5586                        _ => None,
5587                    })
5588                    .collect(),
5589            ))
5590        }
5591        (Value::Text(s), DataType::FloatArray) => Some(Value::FloatArray(
5592            decode_array_elems(&s, DataType::Float, col_name, position)?
5593                .into_iter()
5594                .map(|o| match o {
5595                    Some(Value::Float(f)) => Some(f),
5596                    _ => None,
5597                })
5598                .collect(),
5599        )),
5600        (Value::Text(s), DataType::NumericArray) => Some(Value::NumericArray(
5601            decode_array_elems(
5602                &s,
5603                DataType::Numeric {
5604                    precision: 0,
5605                    scale: 0,
5606                },
5607                col_name,
5608                position,
5609            )?
5610            .into_iter()
5611            .map(|o| match o {
5612                Some(Value::Numeric { scaled, scale, .. }) => Some((scaled, scale)),
5613                _ => None,
5614            })
5615            .collect(),
5616        )),
5617        (Value::Text(s), DataType::DateArray) => Some(Value::DateArray(
5618            decode_array_elems(&s, DataType::Date, col_name, position)?
5619                .into_iter()
5620                .map(|o| match o {
5621                    Some(Value::Date(d)) => Some(d),
5622                    _ => None,
5623                })
5624                .collect(),
5625        )),
5626        (Value::Text(s), DataType::UuidArray) => Some(Value::UuidArray(
5627            decode_array_elems(&s, DataType::Uuid, col_name, position)?
5628                .into_iter()
5629                .map(|o| match o {
5630                    Some(Value::Uuid(u)) => Some(u),
5631                    _ => None,
5632                })
5633                .collect(),
5634        )),
5635        // v7.39 (round 694) — `oid[]` decodes exactly as `bigint[]` does;
5636        // the variant exists to keep the DECLARED type, not to change the
5637        // body. Listed here rather than mapped to BigIntArray upstream
5638        // because mapping it upstream is what made `pg_typeof('{1,2}'::oid[])`
5639        // answer `bigint[]`, which is the defect round 667 closed for the
5640        // scalar.
5641        (Value::Text(s), DataType::BigIntArray | DataType::OidArray) => {
5642            // v7.39 (round 325, V57) — PG's wording (and the same message
5643            // the CAST path gives for the identical input; this one used to
5644            // name TEXT[] whatever the column's element type was).
5645            let arr = decode_text_array_literal(&s).map_err(|_| {
5646                EngineError::Eval(EvalError::TypeMismatch {
5647                    detail: malformed_array_literal(&s),
5648                })
5649            })?;
5650            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
5651            for elem in arr {
5652                match elem {
5653                    None => out.push(None),
5654                    Some(t) => {
5655                        let n: i64 = t.parse().map_err(|_| {
5656                            EngineError::Eval(EvalError::TypeMismatch {
5657                                detail: alloc::format!(
5658                                    "invalid input syntax for type bigint: {t:?}"
5659                                ),
5660                            })
5661                        })?;
5662                        out.push(Some(n));
5663                    }
5664                }
5665            }
5666            Some(Value::BigIntArray(out))
5667        }
5668        // v7.10.11 — TEXT[] → Text round-trip uses PG's
5669        // external array form (`{a,b,NULL}`). Lets a SELECT
5670        // pull an array column through any Text-side codepath.
5671        (Value::TextArray(items), DataType::Text) => Some(Value::text(encode_text_array(&items))),
5672        // v7.37.5 ship triage — empty `ARRAY[]` literal lands as
5673        // `Value::TextArray(vec![])`. Allow widening to the typed
5674        // array sibling so `ARRAY[]::BOOL[]` / `::FLOAT[]` etc.
5675        // round-trip through INSERT into the typed column. Only
5676        // empty contents go through silently — non-empty TextArray
5677        // must round-trip via per-element parsing(handled by the
5678        // existing element-specific coercion paths above).
5679        (Value::TextArray(items), DataType::BoolArray) if items.is_empty() => {
5680            Some(Value::BoolArray(alloc::vec::Vec::new()))
5681        }
5682        (Value::TextArray(items), DataType::SmallIntArray) if items.is_empty() => {
5683            Some(Value::SmallIntArray(alloc::vec::Vec::new()))
5684        }
5685        (Value::TextArray(items), DataType::IntArray) if items.is_empty() => {
5686            Some(Value::IntArray(alloc::vec::Vec::new()))
5687        }
5688        (Value::TextArray(items), DataType::BigIntArray) if items.is_empty() => {
5689            Some(Value::BigIntArray(alloc::vec::Vec::new()))
5690        }
5691        (Value::TextArray(items), DataType::FloatArray) if items.is_empty() => {
5692            Some(Value::FloatArray(alloc::vec::Vec::new()))
5693        }
5694        // `expr::float8[]` — an array literal reaches here as TEXT[] (elements
5695        // rendered to text); parse each element to f64. NULLs pass through.
5696        (Value::TextArray(items), DataType::FloatArray) => {
5697            let mut out = alloc::vec::Vec::with_capacity(items.len());
5698            let mut ok = true;
5699            for item in items {
5700                match item {
5701                    None => out.push(None),
5702                    Some(s) => match s.trim().parse::<f64>() {
5703                        Ok(x) => out.push(Some(x)),
5704                        Err(_) => {
5705                            ok = false;
5706                            break;
5707                        }
5708                    },
5709                }
5710            }
5711            if ok {
5712                Some(Value::FloatArray(out))
5713            } else {
5714                None
5715            }
5716        }
5717        // Identity for an already-float array, and widen integer arrays
5718        // element-wise (PG accepts `ARRAY[1,2]::float8[]`).
5719        (Value::FloatArray(items), DataType::FloatArray) => Some(Value::FloatArray(items)),
5720        #[allow(clippy::cast_precision_loss)]
5721        (Value::IntArray(items), DataType::FloatArray) => Some(Value::FloatArray(
5722            items.into_iter().map(|o| o.map(|n| f64::from(n))).collect(),
5723        )),
5724        #[allow(clippy::cast_precision_loss)]
5725        (Value::BigIntArray(items), DataType::FloatArray) => Some(Value::FloatArray(
5726            items.into_iter().map(|o| o.map(|n| n as f64)).collect(),
5727        )),
5728        // v7.38 (read01) — widen a NUMERIC[] into float8[] element-wise (PG
5729        // accepts `ARRAY[1.5::numeric]::float8[]` and coerces a numeric array
5730        // into a float8[] column on INSERT). Mirrors the scalar Numeric→Float.
5731        #[allow(clippy::cast_precision_loss)]
5732        (Value::NumericArray(items), DataType::FloatArray) => Some(Value::FloatArray(
5733            items
5734                .into_iter()
5735                .map(|o| {
5736                    o.map(|(scaled, scale)| {
5737                        crate::eval::format_numeric(scaled, scale)
5738                            .parse()
5739                            .unwrap_or(f64::NAN)
5740                    })
5741                })
5742                .collect(),
5743        )),
5744        // v7.38 (read01) — the rest of the numeric-array coercion matrix PG
5745        // accepts on INSERT / cast. Widening int→bigint / int·bigint→numeric /
5746        // float→numeric never fails; narrowing bigint→int fails the whole
5747        // coercion (→ None) if any element overflows i32.
5748        (Value::IntArray(items), DataType::BigIntArray) => Some(Value::BigIntArray(
5749            items.into_iter().map(|o| o.map(i64::from)).collect(),
5750        )),
5751        (Value::BigIntArray(items), DataType::IntArray) => {
5752            let mut out = alloc::vec::Vec::with_capacity(items.len());
5753            let mut ok = true;
5754            for o in items {
5755                match o {
5756                    None => out.push(None),
5757                    Some(n) => match i32::try_from(n) {
5758                        Ok(v) => out.push(Some(v)),
5759                        Err(_) => {
5760                            ok = false;
5761                            break;
5762                        }
5763                    },
5764                }
5765            }
5766            if ok { Some(Value::IntArray(out)) } else { None }
5767        }
5768        (Value::IntArray(items), DataType::NumericArray) => Some(Value::NumericArray(
5769            items
5770                .into_iter()
5771                .map(|o| o.map(|n| (i128::from(n), 0_u16)))
5772                .collect(),
5773        )),
5774        (Value::BigIntArray(items), DataType::NumericArray) => Some(Value::NumericArray(
5775            items
5776                .into_iter()
5777                .map(|o| o.map(|n| (i128::from(n), 0_u16)))
5778                .collect(),
5779        )),
5780        (Value::FloatArray(items), DataType::NumericArray) => {
5781            let mut out = alloc::vec::Vec::with_capacity(items.len());
5782            let mut ok = true;
5783            for o in items {
5784                match o {
5785                    None => out.push(None),
5786                    Some(x) => match parse_numeric_text(&alloc::format!("{x}")) {
5787                        Some((mantissa, scale)) => out.push(Some((mantissa, scale))),
5788                        None => {
5789                            ok = false;
5790                            break;
5791                        }
5792                    },
5793                }
5794            }
5795            if ok {
5796                Some(Value::NumericArray(out))
5797            } else {
5798                None
5799            }
5800        }
5801        // v7.38 (read01) — narrow a NUMERIC[] into int[] / bigint[] element-wise,
5802        // rounding half away from zero (PG) like the scalar Numeric→Int coercion.
5803        // An out-of-range element fails the whole coercion (→ None).
5804        (Value::NumericArray(items), DataType::IntArray) => {
5805            let mut out = alloc::vec::Vec::with_capacity(items.len());
5806            let mut ok = true;
5807            for o in items {
5808                match o {
5809                    None => out.push(None),
5810                    Some((scaled, scale)) => {
5811                        match i32::try_from(numeric_round_to_integer(scaled, scale)) {
5812                            Ok(v) => out.push(Some(v)),
5813                            Err(_) => {
5814                                ok = false;
5815                                break;
5816                            }
5817                        }
5818                    }
5819                }
5820            }
5821            if ok { Some(Value::IntArray(out)) } else { None }
5822        }
5823        (Value::NumericArray(items), DataType::BigIntArray) => {
5824            let mut out = alloc::vec::Vec::with_capacity(items.len());
5825            let mut ok = true;
5826            for o in items {
5827                match o {
5828                    None => out.push(None),
5829                    Some((scaled, scale)) => {
5830                        match i64::try_from(numeric_round_to_integer(scaled, scale)) {
5831                            Ok(v) => out.push(Some(v)),
5832                            Err(_) => {
5833                                ok = false;
5834                                break;
5835                            }
5836                        }
5837                    }
5838                }
5839            }
5840            if ok {
5841                Some(Value::BigIntArray(out))
5842            } else {
5843                None
5844            }
5845        }
5846        // v7.38 (read01, T2) — float8[] → int[] / bigint[], rounding each element
5847        // half-to-even (PG's float→int rule, distinct from numeric's half-away).
5848        // A non-finite / out-of-range element fails the whole coercion.
5849        #[allow(clippy::cast_possible_truncation)]
5850        (Value::FloatArray(items), DataType::IntArray) => {
5851            let mut out = alloc::vec::Vec::with_capacity(items.len());
5852            let mut ok = true;
5853            for o in items {
5854                match o {
5855                    None => out.push(None),
5856                    Some(x) if x.is_finite() => {
5857                        let r = crate::eval::math::f64_round_half_even(x);
5858                        if r >= f64::from(i32::MIN) && r <= f64::from(i32::MAX) {
5859                            out.push(Some(r as i32));
5860                        } else {
5861                            ok = false;
5862                            break;
5863                        }
5864                    }
5865                    Some(_) => {
5866                        ok = false;
5867                        break;
5868                    }
5869                }
5870            }
5871            if ok { Some(Value::IntArray(out)) } else { None }
5872        }
5873        #[allow(clippy::cast_possible_truncation)]
5874        (Value::FloatArray(items), DataType::BigIntArray) => {
5875            let mut out = alloc::vec::Vec::with_capacity(items.len());
5876            let mut ok = true;
5877            for o in items {
5878                match o {
5879                    None => out.push(None),
5880                    Some(x) if x.is_finite() => {
5881                        out.push(Some(crate::eval::math::f64_round_half_even(x) as i64));
5882                    }
5883                    Some(_) => {
5884                        ok = false;
5885                        break;
5886                    }
5887                }
5888            }
5889            if ok {
5890                Some(Value::BigIntArray(out))
5891            } else {
5892                None
5893            }
5894        }
5895        (Value::TextArray(items), DataType::NumericArray) if items.is_empty() => {
5896            Some(Value::NumericArray(alloc::vec::Vec::new()))
5897        }
5898        (Value::TextArray(items), DataType::DateArray) if items.is_empty() => {
5899            Some(Value::DateArray(alloc::vec::Vec::new()))
5900        }
5901        (Value::TextArray(items), DataType::TimestampArray) if items.is_empty() => {
5902            Some(Value::TimestampArray(alloc::vec::Vec::new()))
5903        }
5904        (Value::TextArray(items), DataType::TimestamptzArray) if items.is_empty() => {
5905            Some(Value::TimestamptzArray(alloc::vec::Vec::new()))
5906        }
5907        (Value::TextArray(items), DataType::UuidArray) if items.is_empty() => {
5908            Some(Value::UuidArray(alloc::vec::Vec::new()))
5909        }
5910        (Value::TextArray(items), DataType::JsonArray) if items.is_empty() => {
5911            Some(Value::JsonArray(alloc::vec::Vec::new()))
5912        }
5913        (Value::TextArray(items), DataType::JsonbArray) if items.is_empty() => {
5914            Some(Value::JsonbArray(alloc::vec::Vec::new()))
5915        }
5916        (Value::TextArray(items), DataType::BytesArray) if items.is_empty() => {
5917            Some(Value::BytesArray(alloc::vec::Vec::new()))
5918        }
5919        (Value::TextArray(items), DataType::IntervalArray) if items.is_empty() => {
5920            Some(Value::IntervalArray(alloc::vec::Vec::new()))
5921        }
5922        // Non-empty `TEXT[]` → typed array (`ARRAY[..]::bool[]`, `::numeric[]`,
5923        // `::date[]`, `::timestamp[]`, `::uuid[]`): parse each element via the
5924        // scalar path. Empty arrays are handled by the arms above.
5925        (
5926            Value::TextArray(items),
5927            dt @ (DataType::BoolArray
5928            | DataType::NumericArray
5929            | DataType::DateArray
5930            | DataType::TimestampArray
5931            | DataType::TimestamptzArray
5932            | DataType::IntervalArray
5933            | DataType::UuidArray),
5934        ) => coerce_text_array_to(items, dt, col_name)?,
5935        // v7.39 (round 326, V43) — the same targets from a STRING LITERAL.
5936        // `'{1,2}'::int[]` had a Text arm and worked; `'{…}'::timestamp[]`,
5937        // `::timestamptz[]` and `::interval[]` had none, so the literal
5938        // stayed TEXT and the cast died as a plain type mismatch — a whole
5939        // literal form that simply did not exist for the temporal arrays.
5940        (
5941            Value::Text(s),
5942            dt @ (DataType::TimestampArray | DataType::TimestamptzArray | DataType::IntervalArray),
5943        ) => {
5944            let items = decode_text_array_literal(&s).map_err(|_| {
5945                EngineError::Eval(EvalError::TypeMismatch {
5946                    detail: malformed_array_literal(&s),
5947                })
5948            })?;
5949            coerce_text_array_to(items, dt, col_name)?
5950        }
5951        (Value::TextArray(items), DataType::MoneyArray) if items.is_empty() => {
5952            Some(Value::MoneyArray(alloc::vec::Vec::new()))
5953        }
5954        // v7.37.5 ship triage — IntArray(empty) widens to
5955        // SmallIntArray for the `INSERT INTO t (xs) VALUES
5956        // (ARRAY[1::smallint, …])` path where the array literal
5957        // collected mixed int widths into IntArray.
5958        (Value::IntArray(items), DataType::SmallIntArray) => {
5959            let mut out = alloc::vec::Vec::with_capacity(items.len());
5960            let mut ok = true;
5961            for item in items {
5962                match item {
5963                    None => out.push(None),
5964                    Some(n) => match i16::try_from(n) {
5965                        Ok(x) => out.push(Some(x)),
5966                        Err(_) => {
5967                            ok = false;
5968                            break;
5969                        }
5970                    },
5971                }
5972            }
5973            if ok {
5974                Some(Value::SmallIntArray(out))
5975            } else {
5976                None
5977            }
5978        }
5979        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
5980        // Matches the existing Text → TsVector arm and the
5981        // `::vector` cast: PG-canonical pgvector external form
5982        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
5983        // column boundary. Dim mismatch surfaces as TypeMismatch.
5984        // For SQ8 / HALF encodings we chain through the standard
5985        // quantise helpers so the storage shape matches the
5986        // declared encoding without a second coerce pass.
5987        (Value::Text(s), DataType::Vector { dim, encoding }) => {
5988            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
5989                EngineError::Eval(EvalError::TypeMismatch {
5990                    detail: alloc::format!("cannot parse {s:?} as VECTOR"),
5991                })
5992            })?;
5993            if parsed.len() != dim as usize {
5994                return Err(EngineError::Eval(EvalError::TypeMismatch {
5995                    detail: alloc::format!(
5996                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
5997                        parsed.len()
5998                    ),
5999                }));
6000            }
6001            Some(match encoding {
6002                VecEncoding::F32 => Value::vector(parsed),
6003                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
6004                VecEncoding::F16 => {
6005                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
6006                }
6007            })
6008        }
6009        // v7.16.1 — Text → TSVECTOR auto-coerce for the
6010        // INSERT-side wire path (mailrs round-9 A.2.a). PG
6011        // implicitly promotes the TEXT literal at INSERT into a
6012        // TSVECTOR column; SPG previously rejected with a hard
6013        // type mismatch, blocking 23,276 pg_dump rows into
6014        // `messages.search_vector`. We route through the same
6015        // `decode_tsvector_external` the `::tsvector` cast
6016        // already uses, so PG-canonical forms (`'word'`,
6017        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
6018        (Value::Text(s), DataType::TsVector) => {
6019            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
6020                EngineError::Eval(EvalError::TypeMismatch {
6021                    detail: alloc::format!("cannot parse {s:?} as TSVECTOR: {e}"),
6022                })
6023            })?;
6024            Some(Value::TsVector(lexs))
6025        }
6026        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
6027            let t = eval::parse_timestamp_literal(&s)
6028                .ok_or_else(|| datetime_parse_error("timestamp", &s))?;
6029            Some(Value::Timestamp(t))
6030        }
6031        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
6032        // TIMESTAMP → day truncation).
6033        (Value::Date(i32::MAX), DataType::Timestamp | DataType::Timestamptz) => {
6034            Some(Value::Timestamp(i64::MAX))
6035        }
6036        (Value::Date(i32::MIN), DataType::Timestamp | DataType::Timestamptz) => {
6037            Some(Value::Timestamp(i64::MIN))
6038        }
6039        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
6040            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
6041        }
6042        // v7.9.21 — Value::Timestamp lands in either Timestamp
6043        // or Timestamptz columns; the on-disk layout is the
6044        // same i64 microseconds UTC.
6045        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
6046        (Value::Timestamp(t), DataType::Date) => {
6047            let days = t.div_euclid(86_400_000_000);
6048            i32::try_from(days).ok().map(Value::Date)
6049        }
6050        // v7.39 (round 633) — the time of day out of a timestamp.
6051        //
6052        // `TIMESTAMP '2020-01-02 03:04:05'::TIME` answered "cannot cast
6053        // timestamp without time zone to time without time zone"; PG
6054        // answers `03:04:05`, and has the cast registered as an assignment
6055        // one. `rem_euclid` rather than `%` so a pre-epoch timestamp gives
6056        // a time in [0, 24h) instead of a negative one. A timestamptz value
6057        // is carried in the same variant, so it comes through here too.
6058        (Value::Timestamp(t), DataType::Time) => Some(Value::Time(t.rem_euclid(86_400_000_000))),
6059        // v7.39 (read01 numeric.c) — a NumericBig is already an unconstrained
6060        // NUMERIC ('…0.5::numeric' where the mantissa exceeds i128); pass it
6061        // through. A declared numeric(p, s) still falls to the typed error.
6062        (
6063            Value::NumericBig(b),
6064            DataType::Numeric {
6065                precision: 0,
6066                scale: 0,
6067            },
6068        ) => Some(Value::NumericBig(b)),
6069        (
6070            Value::Numeric {
6071                scaled,
6072                scale: src_scale,
6073                ..
6074            },
6075            DataType::Numeric { precision, scale },
6076        ) => {
6077            // v7.38 (read01) — the unconstrained `::numeric` sentinel (0, 0)
6078            // keeps the value's natural scale, matching the Float/Text→Numeric
6079            // arms above; only a declared numeric(p, s) rescales. Without this,
6080            // casting an existing NUMERIC through unconstrained numeric
6081            // (`n::numeric(5,2)::numeric`) rounded it to scale 0.
6082            if precision == 0 && scale == 0 {
6083                Some(Value::Numeric {
6084                    scaled,
6085                    scale: src_scale,
6086                    kind: spg_storage::NumericKind::Finite,
6087                })
6088            } else {
6089                Some(numeric_rescale(
6090                    scaled, src_scale, precision, scale, col_name,
6091                )?)
6092            }
6093        }
6094        // v7.39 (round 272) — an arbitrary-precision value cast to a
6095        // DECLARED numeric had no arm at all, so a 47-digit literal
6096        // going into numeric(50,2) — a column PG accepts — reported an
6097        // internal storage type mismatch.
6098        (Value::NumericBig(b), DataType::Numeric { precision, scale }) => {
6099            if precision == 0 && scale == 0 {
6100                Some(Value::NumericBig(b))
6101            } else {
6102                #[allow(clippy::cast_sign_loss)]
6103                let rounded = if scale < 0 {
6104                    // Round to the multiple of 10^|scale| and land at 0.
6105                    b.round_to(0)
6106                } else {
6107                    b.round_to(scale as u16)
6108                };
6109                let out = crate::eval::binop::bignum_to_value(rounded);
6110                // The declared precision still binds; check it on the
6111                // decimal text, which both forms can produce.
6112                crate::numeric::check_precision_text(&out, precision, scale, col_name)?;
6113                Some(out)
6114            }
6115        }
6116        #[allow(clippy::cast_precision_loss)]
6117        (Value::Numeric { scaled, scale, .. }, DataType::Float) => {
6118            // v7.39 (round 271) — parse the decimal text rather than
6119            // dividing by a power built with repeated multiplication.
6120            // With scale widened to u16 that loop both accumulated
6121            // rounding error (1e-300 came out 9.999999999999999e-301)
6122            // and ran to infinity for a large enough scale, which then
6123            // looked like an underflow.
6124            let text = crate::eval::format_numeric(scaled, scale);
6125            let x: f64 = text.parse().unwrap_or(f64::NAN);
6126            // v7.39 (round 270) — a nonzero NUMERIC that underflows the
6127            // double range is an error in PG, quoting the decimal
6128            // expansion. It used to arrive as a silent zero.
6129            if x == 0.0 && scaled != 0 {
6130                return Err(float_out_of_range(
6131                    &crate::eval::format_numeric(scaled, scale),
6132                    "double precision",
6133                ));
6134            }
6135            Some(Value::Float(x))
6136        }
6137        // v7.39 (read01 numeric.c) — a big NUMERIC (`3.14e100` literal) casts
6138        // to float8 through its decimal text; a value beyond the double range
6139        // errors like PG ("value out of range: overflow").
6140        // v7.39 (round 269) — the same route to real. Without this arm a
6141        // NUMERIC literal past the i128 range (1.8e38 and up) never
6142        // reached a real cast at all and surfaced an internal
6143        // "expected REAL, got NUMERIC(0)" storage mismatch.
6144        (Value::NumericBig(b), DataType::Real) => {
6145            let text = b.to_decimal_str();
6146            let x: f32 = text.parse().map_err(|_| real_out_of_range(&text))?;
6147            if !x.is_finite() || (x == 0.0 && float_text_is_nonzero(&text)) {
6148                return Err(real_out_of_range(&text));
6149            }
6150            Some(Value::Real(x))
6151        }
6152        (Value::NumericBig(b), DataType::Float) => {
6153            // v7.39 (round 270) — PG quotes the decimal expansion here
6154            // rather than saying "value out of range: overflow", which
6155            // it reserves for narrowing a double.
6156            let text = b.to_decimal_str();
6157            let x: f64 = text
6158                .parse()
6159                .map_err(|_| float_out_of_range(&text, "double precision"))?;
6160            if !x.is_finite() || (x == 0.0 && float_text_is_nonzero(&text)) {
6161                return Err(float_out_of_range(&text, "double precision"));
6162            }
6163            Some(Value::Float(x))
6164        }
6165        // v7.38 (read01) — coercing NUMERIC into an integer column rounds half
6166        // away from zero (PG assignment cast: `1.5 → 2`), matching the `::int`
6167        // cast path; it previously truncated (`1.7 → 1`).
6168        // v7.39 (read01 float.c) — float → integer coercion (int4()/int8()/
6169        // int2() function casts, INSERT float into int column): PG rounds
6170        // half-to-even and errors on a non-finite / out-of-range value
6171        // rather than saturating.
6172        (Value::Float(x), DataType::Int) => {
6173            let r = crate::eval::math::f64_round_half_even(x);
6174            if !r.is_finite() || !(-2_147_483_648.0..=2_147_483_647.0).contains(&r) {
6175                return Err(EngineError::Eval(EvalError::TypeMismatch {
6176                    detail: "integer out of range".into(),
6177                }));
6178            }
6179            #[allow(clippy::cast_possible_truncation)]
6180            Some(Value::Int(r as i32))
6181        }
6182        (Value::Float(x), DataType::BigInt) => {
6183            let r = crate::eval::math::f64_round_half_even(x);
6184            if !r.is_finite()
6185                || !(-9.223_372_036_854_776e18..=9.223_372_036_854_776e18).contains(&r)
6186            {
6187                return Err(EngineError::Eval(EvalError::TypeMismatch {
6188                    detail: "bigint out of range".into(),
6189                }));
6190            }
6191            #[allow(clippy::cast_possible_truncation)]
6192            Some(Value::BigInt(r as i64))
6193        }
6194        (Value::Float(x), DataType::SmallInt) => {
6195            let r = crate::eval::math::f64_round_half_even(x);
6196            if !r.is_finite() || !(-32768.0..=32767.0).contains(&r) {
6197                return Err(EngineError::Eval(EvalError::TypeMismatch {
6198                    detail: "smallint out of range".into(),
6199                }));
6200            }
6201            #[allow(clippy::cast_possible_truncation)]
6202            Some(Value::SmallInt(r as i16))
6203        }
6204        // v7.39 (read01 round 112) — REAL (float4) → integer types. Mirrors the
6205        // float8 arms above (round half-to-even, PG's rule); these had no arm at
6206        // all, so `real::int` errored "cannot cast Real to int".
6207        (Value::Real(x), DataType::Int) => {
6208            let r = crate::eval::math::f64_round_half_even(f64::from(x));
6209            if !r.is_finite() || !(-2_147_483_648.0..=2_147_483_647.0).contains(&r) {
6210                return Err(EngineError::Eval(EvalError::TypeMismatch {
6211                    detail: "integer out of range".into(),
6212                }));
6213            }
6214            #[allow(clippy::cast_possible_truncation)]
6215            Some(Value::Int(r as i32))
6216        }
6217        (Value::Real(x), DataType::BigInt) => {
6218            let r = crate::eval::math::f64_round_half_even(f64::from(x));
6219            if !r.is_finite()
6220                || !(-9.223_372_036_854_776e18..=9.223_372_036_854_776e18).contains(&r)
6221            {
6222                return Err(EngineError::Eval(EvalError::TypeMismatch {
6223                    detail: "bigint out of range".into(),
6224                }));
6225            }
6226            #[allow(clippy::cast_possible_truncation)]
6227            Some(Value::BigInt(r as i64))
6228        }
6229        (Value::Real(x), DataType::SmallInt) => {
6230            let r = crate::eval::math::f64_round_half_even(f64::from(x));
6231            if !r.is_finite() || !(-32768.0..=32767.0).contains(&r) {
6232                return Err(EngineError::Eval(EvalError::TypeMismatch {
6233                    detail: "smallint out of range".into(),
6234                }));
6235            }
6236            #[allow(clippy::cast_possible_truncation)]
6237            Some(Value::SmallInt(r as i16))
6238        }
6239        (Value::Numeric { scaled, scale, .. }, DataType::Int) => {
6240            let rounded = numeric_round_to_integer(scaled, scale);
6241            i32::try_from(rounded).ok().map(Value::Int)
6242        }
6243        (Value::Numeric { scaled, scale, .. }, DataType::BigInt) => {
6244            let rounded = numeric_round_to_integer(scaled, scale);
6245            i64::try_from(rounded).ok().map(Value::BigInt)
6246        }
6247        (Value::Numeric { scaled, scale, .. }, DataType::SmallInt) => {
6248            let rounded = numeric_round_to_integer(scaled, scale);
6249            i16::try_from(rounded).ok().map(Value::SmallInt)
6250        }
6251        // VARCHAR(n) enforces an upper bound on character count. A bare
6252        // `varchar` (no typmod) is modelled as `Varchar(0)` and, like PG, holds
6253        // a string of any length — `'a'::varchar` must not read as VARCHAR(0).
6254        // v7.39 (round 291) — `name` is text truncated to NAMEDATALEN-1
6255        // (63) bytes. PG truncates silently rather than erroring, which
6256        // is the behaviour a catalog identifier column needs.
6257        (Value::Text(s), DataType::Name) => {
6258            let mut cut = s.into_owned();
6259            if cut.len() > 63 {
6260                let mut idx = 63;
6261                while !cut.is_char_boundary(idx) {
6262                    idx -= 1;
6263                }
6264                cut.truncate(idx);
6265            }
6266            Some(Value::text(cut))
6267        }
6268        (Value::Text(s), DataType::Varchar(max)) => {
6269            if max == 0 || u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
6270                Some(Value::text(s))
6271            } else {
6272                // v7.39 (bpchar epic) — overflow that is only trailing
6273                // blanks is cut AT the limit (PG keeps 'abcd ' from
6274                // 'abcd  ' in varchar(5) — not a full strip); anything
6275                // else is 22001 with PG's phrasing.
6276                let excess_all_blanks = s.chars().skip(max as usize).all(|c| c == ' ');
6277                if excess_all_blanks {
6278                    Some(Value::text(
6279                        s.chars()
6280                            .take(max as usize)
6281                            .collect::<alloc::string::String>(),
6282                    ))
6283                } else {
6284                    return Err(EngineError::Unsupported(alloc::format!(
6285                        "value too long for type character varying({max})"
6286                    )));
6287                }
6288            }
6289        }
6290        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
6291        // when the column declares `VECTOR(N) USING SQ8` and
6292        // the INSERT VALUES expression yields a raw f32 vector
6293        // (the normal pgvector-shape literal). Dim mismatch
6294        // falls through the `_ => None` arm and surfaces as
6295        // `TypeMismatch` with the expected SQ8 column type —
6296        // matching the F32 path's existing error.
6297        (
6298            Value::Vector(v),
6299            DataType::Vector {
6300                dim,
6301                encoding: VecEncoding::Sq8,
6302            },
6303        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
6304        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
6305        // columns. Bit-exact at the storage layer (modulo
6306        // half-precision rounding); no rerank pass needed at
6307        // search time.
6308        (
6309            Value::Vector(v),
6310            DataType::Vector {
6311                dim,
6312                encoding: VecEncoding::F16,
6313            },
6314        ) if v.len() == dim as usize => Some(Value::HalfVector(
6315            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
6316        )),
6317        // CHAR(n) right-pads with U+0020 to exactly n chars. Overflow that
6318        // is only trailing blanks is trimmed to fit (PG: 'abcd  ' fits
6319        // CHAR(5)); real overflow is 22001.
6320        (Value::Text(s), DataType::Char(size)) => {
6321            // v7.39 (bpchar epic) — bare `bpchar` (no length) is PG's
6322            // unlimited blank-trimmed character type: store stripped,
6323            // no pad, no length check.
6324            if size == 0 {
6325                return Ok(Value::BpChar(alloc::borrow::Cow::Owned(
6326                    s.trim_end_matches(' ').to_string(),
6327                )));
6328            }
6329            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
6330            let body = if len > size {
6331                let trimmed = s.trim_end_matches(' ');
6332                let tlen = u32::try_from(trimmed.chars().count()).unwrap_or(u32::MAX);
6333                if tlen > size {
6334                    return Err(EngineError::Unsupported(alloc::format!(
6335                        "value too long for type character({size})"
6336                    )));
6337                }
6338                trimmed.to_string()
6339            } else {
6340                s.into_owned()
6341            };
6342            let need = (size as usize) - body.chars().count();
6343            let mut padded = body;
6344            padded.reserve(need);
6345            for _ in 0..need {
6346                padded.push(' ');
6347            }
6348            // v7.38 (read01, T11) — CHAR(n) is bpchar: blank-padded, and
6349            // length / comparison / ::text ignore the padding (handled at those
6350            // sites).
6351            Some(Value::BpChar(alloc::borrow::Cow::Owned(padded)))
6352        }
6353        _ => None,
6354    };
6355    coerced.ok_or_else(|| {
6356        EngineError::Storage(StorageError::TypeMismatch {
6357            column: col_name.into(),
6358            expected,
6359            actual,
6360            position,
6361        })
6362    })
6363}
6364
6365/// v7.38 (read01, T3.C3) — a lexer-validated big decimal literal → NumericBig,
6366/// demoted to a plain Numeric if its mantissa happens to fit i128.
6367pub(crate) fn big_literal_to_value(s: &str) -> Value<'static> {
6368    let b = spg_storage::bignum::BigNumeric::from_decimal_str(s).expect("lexer-validated decimal");
6369    match b.to_i128() {
6370        Some(scaled) => Value::Numeric {
6371            scaled,
6372            scale: b.scale(),
6373            kind: spg_storage::NumericKind::Finite,
6374        },
6375        None => Value::NumericBig(alloc::boxed::Box::new(b)),
6376    }
6377}
6378
6379/// v7.39 (round 233 / round 236) — do two types share a PG type category,
6380/// so a set operation, an ARRAY constructor, a VALUES list, CASE, COALESCE
6381/// or GREATEST/LEAST can resolve them to one result type? Same type
6382/// always does; otherwise PG unifies within the numeric, string and
6383/// date/time families and refuses across them (probed against 18.4:
6384/// int ∪ bigint → bigint, text ∪ varchar → text, date ∪ timestamp →
6385/// timestamp, but int ∪ boolean, int ∪ text and text ∪ date are all
6386/// refused).
6387pub(crate) fn types_unify(a: DataType, b: DataType) -> bool {
6388    fn category(t: DataType) -> Option<u8> {
6389        Some(match t {
6390            DataType::SmallInt
6391            | DataType::Int
6392            | DataType::BigInt
6393            | DataType::Numeric { .. }
6394            | DataType::Real
6395            | DataType::Float => 1,
6396            DataType::Text | DataType::Varchar(_) | DataType::Char(_) => 2,
6397            DataType::Date | DataType::Timestamp | DataType::Timestamptz => 3,
6398            _ => return None,
6399        })
6400    }
6401    if a == b {
6402        return true;
6403    }
6404    match (category(a), category(b)) {
6405        (Some(x), Some(y)) => x == y,
6406        // Outside the families a set operation needs the exact same type;
6407        // `a == b` above already covered that.
6408        _ => false,
6409    }
6410}
6411
6412/// v7.39 (round 236) — the type name PG puts in a "types X and Y cannot be
6413/// matched" message. `pg_data_type_text` answers for
6414/// `information_schema.columns.data_type`, where every array is the
6415/// pseudo-name `ARRAY`; an error message names the real thing
6416/// (`integer[]`).
6417/// v7.39 (round 622, S05a) — the `Option<DataType>` form, which is what
6418/// `Value::data_type()` returns and therefore what every "got X" error had.
6419///
6420/// Those errors printed it with `{:?}`, so a user asking for `upper(1)` was
6421/// told the argument was `Some(Int)` — Rust's Debug for an Option wrapping an
6422/// internal enum. 421 sites did this. `None` is the eval-only variants that
6423/// carry no storage type (RegClass, Composite); PG calls an untyped value
6424/// `unknown`, and that is what it becomes here.
6425pub(crate) fn pg_type_name_for_error_opt(t: Option<DataType>) -> alloc::string::String {
6426    match t {
6427        Some(t) => pg_type_name_for_error(t),
6428        None => alloc::string::String::from("unknown"),
6429    }
6430}
6431
6432pub(crate) fn pg_type_name_for_error(t: DataType) -> alloc::string::String {
6433    use spg_storage::DataType as D;
6434    let elem = match t {
6435        D::TextArray => Some(D::Text),
6436        D::IntArray => Some(D::Int),
6437        D::BigIntArray => Some(D::BigInt),
6438        D::SmallIntArray => Some(D::SmallInt),
6439        D::FloatArray => Some(D::Float),
6440        D::NumericArray => Some(D::Numeric {
6441            precision: 0,
6442            scale: 0,
6443        }),
6444        D::BoolArray => Some(D::Bool),
6445        D::DateArray => Some(D::Date),
6446        D::TimestampArray => Some(D::Timestamp),
6447        D::TimestamptzArray => Some(D::Timestamptz),
6448        D::IntervalArray => Some(D::Interval),
6449        D::UuidArray => Some(D::Uuid),
6450        D::JsonArray | D::JsonbArray => Some(D::Jsonb),
6451        D::BytesArray => Some(D::Bytes),
6452        D::MoneyArray => Some(D::Money),
6453        _ => None,
6454    };
6455    match elem {
6456        Some(e) => alloc::format!("{}[]", crate::system_catalog::pg_data_type_text(e)),
6457        None => crate::system_catalog::pg_data_type_text(t),
6458    }
6459}