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