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