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