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