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_truncate_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).
25pub(crate) fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
26    let s = s.trim();
27    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
28        // Hex form. Each pair of hex digits → one byte.
29        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
30        if cleaned.len() % 2 != 0 {
31            return Err("odd-length hex literal");
32        }
33        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
34        let cleaned_bytes = cleaned.as_bytes();
35        for i in (0..cleaned_bytes.len()).step_by(2) {
36            let hi = hex_nibble(cleaned_bytes[i])?;
37            let lo = hex_nibble(cleaned_bytes[i + 1])?;
38            out.push((hi << 4) | lo);
39        }
40        return Ok(out);
41    }
42    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
43    // sequences decode; anything else is a literal byte.
44    let bytes = s.as_bytes();
45    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
46    let mut i = 0;
47    while i < bytes.len() {
48        let b = bytes[i];
49        if b == b'\\' && i + 1 < bytes.len() {
50            let n = bytes[i + 1];
51            if n == b'\\' {
52                out.push(b'\\');
53                i += 2;
54                continue;
55            }
56            if n.is_ascii_digit()
57                && i + 3 < bytes.len()
58                && bytes[i + 2].is_ascii_digit()
59                && bytes[i + 3].is_ascii_digit()
60            {
61                let oct = |x: u8| (x - b'0') as u32;
62                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
63                if v <= 0xFF {
64                    out.push(v as u8);
65                    i += 4;
66                    continue;
67                }
68            }
69        }
70        out.push(b);
71        i += 1;
72    }
73    Ok(out)
74}
75
76pub(crate) fn hex_nibble(b: u8) -> Result<u8, &'static str> {
77    match b {
78        b'0'..=b'9' => Ok(b - b'0'),
79        b'a'..=b'f' => Ok(b - b'a' + 10),
80        b'A'..=b'F' => Ok(b - b'A' + 10),
81        _ => Err("invalid hex digit"),
82    }
83}
84
85/// v7.37.5 γ — uniform array-of-scalar shape detector. Returns
86/// `Some(kind)` only when every non-NULL element fits the same
87/// new-array element type; `None` falls back to the legacy
88/// `array_literal_widen` Int/BigInt/Text path.
89#[derive(Clone, Copy)]
90enum UniformArrayKind {
91    Bool,
92    Float,
93    Numeric,
94    Date,
95    Timestamp,
96    Uuid,
97    Bytes,
98    Interval,
99    Money,
100}
101
102impl UniformArrayKind {
103    fn build(self, items: alloc::vec::Vec<Value<'static>>) -> Value<'static> {
104        match self {
105            Self::Bool => Value::BoolArray(
106                items
107                    .into_iter()
108                    .map(|v| match v {
109                        Value::Null => None,
110                        Value::Bool(b) => Some(b),
111                        _ => unreachable!("uniform Bool"),
112                    })
113                    .collect(),
114            ),
115            Self::Float => Value::FloatArray(
116                items
117                    .into_iter()
118                    .map(|v| match v {
119                        Value::Null => None,
120                        Value::Float(x) => Some(x),
121                        _ => unreachable!("uniform Float"),
122                    })
123                    .collect(),
124            ),
125            Self::Numeric => Value::NumericArray(
126                items
127                    .into_iter()
128                    .map(|v| match v {
129                        Value::Null => None,
130                        Value::Numeric { scaled, scale } => Some((scaled, scale)),
131                        _ => unreachable!("uniform Numeric"),
132                    })
133                    .collect(),
134            ),
135            Self::Date => Value::DateArray(
136                items
137                    .into_iter()
138                    .map(|v| match v {
139                        Value::Null => None,
140                        Value::Date(d) => Some(d),
141                        _ => unreachable!("uniform Date"),
142                    })
143                    .collect(),
144            ),
145            Self::Timestamp => Value::TimestampArray(
146                items
147                    .into_iter()
148                    .map(|v| match v {
149                        Value::Null => None,
150                        Value::Timestamp(t) => Some(t),
151                        _ => unreachable!("uniform Timestamp"),
152                    })
153                    .collect(),
154            ),
155            Self::Uuid => Value::UuidArray(
156                items
157                    .into_iter()
158                    .map(|v| match v {
159                        Value::Null => None,
160                        Value::Uuid(b) => Some(b),
161                        _ => unreachable!("uniform Uuid"),
162                    })
163                    .collect(),
164            ),
165            Self::Bytes => Value::BytesArray(
166                items
167                    .into_iter()
168                    .map(|v| match v {
169                        Value::Null => None,
170                        Value::Bytes(b) => Some(b.into_owned()),
171                        _ => unreachable!("uniform Bytes"),
172                    })
173                    .collect(),
174            ),
175            Self::Interval => Value::IntervalArray(
176                items
177                    .into_iter()
178                    .map(|v| match v {
179                        Value::Null => None,
180                        Value::Interval {
181                            months,
182                            days,
183                            micros,
184                        } => Some(spg_storage::IntervalSpan {
185                            months,
186                            days,
187                            micros,
188                        }),
189                        _ => unreachable!("uniform Interval"),
190                    })
191                    .collect(),
192            ),
193            Self::Money => Value::MoneyArray(
194                items
195                    .into_iter()
196                    .map(|v| match v {
197                        Value::Null => None,
198                        Value::Money(c) => Some(c),
199                        _ => unreachable!("uniform Money"),
200                    })
201                    .collect(),
202            ),
203        }
204    }
205}
206
207fn widen_uniform_typed(items: &[Value<'static>]) -> Option<UniformArrayKind> {
208    let mut kind: Option<UniformArrayKind> = None;
209    let mut saw_non_null = false;
210    for v in items {
211        let this = match v {
212            Value::Null => continue,
213            Value::Bool(_) => UniformArrayKind::Bool,
214            Value::Float(_) => UniformArrayKind::Float,
215            Value::Numeric { .. } => UniformArrayKind::Numeric,
216            Value::Date(_) => UniformArrayKind::Date,
217            Value::Timestamp(_) => UniformArrayKind::Timestamp,
218            Value::Uuid(_) => UniformArrayKind::Uuid,
219            Value::Bytes(_) => UniformArrayKind::Bytes,
220            Value::Interval { .. } => UniformArrayKind::Interval,
221            Value::Money(_) => UniformArrayKind::Money,
222            // Int / BigInt / Text / Json — defer to the legacy
223            // Int/Text widen below so the existing IntArray /
224            // BigIntArray / TextArray behaviour is unchanged.
225            _ => return None,
226        };
227        match kind {
228            None => kind = Some(this),
229            Some(prev) if discriminant_eq(prev, this) => {}
230            Some(_) => return None,
231        }
232        saw_non_null = true;
233    }
234    if saw_non_null { kind } else { None }
235}
236
237fn discriminant_eq(a: UniformArrayKind, b: UniformArrayKind) -> bool {
238    matches!(
239        (a, b),
240        (UniformArrayKind::Bool, UniformArrayKind::Bool)
241            | (UniformArrayKind::Float, UniformArrayKind::Float)
242            | (UniformArrayKind::Numeric, UniformArrayKind::Numeric)
243            | (UniformArrayKind::Date, UniformArrayKind::Date)
244            | (UniformArrayKind::Timestamp, UniformArrayKind::Timestamp)
245            | (UniformArrayKind::Uuid, UniformArrayKind::Uuid)
246            | (UniformArrayKind::Bytes, UniformArrayKind::Bytes)
247            | (UniformArrayKind::Interval, UniformArrayKind::Interval)
248            | (UniformArrayKind::Money, UniformArrayKind::Money)
249    )
250}
251
252/// v7.10.11 — decode a PG TEXT[] external array form
253/// (`{a,b,NULL}` with optional double-quoted elements). The
254/// engine takes a leading/trailing `{`/`}` and splits at commas.
255/// Quoted elements (`"hello, world"`) preserve embedded commas;
256/// `\\` and `\"` decode to literal backslash / quote. Plain
257/// unquoted `NULL` (case-insensitive) maps to `None`.
258/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
259/// element values. Single-element-type rules:
260///   - all NULL / all Text → TextArray
261///   - all Int (or Int+NULL) → IntArray
262///   - any BigInt without Text → BigIntArray (widening)
263///   - any Text → TextArray (fallback; non-string elements
264///     render as text)
265pub(crate) fn array_literal_widen(items: alloc::vec::Vec<Value<'static>>) -> Value<'static> {
266    // v7.37.5 γ — first, detect a uniform new-array-type. If every
267    // non-NULL element shares one of the array-of-scalar element
268    // shapes (Bool / Float / Numeric / Date / Timestamp / Uuid /
269    // Bytes / Interval), build the matching typed array directly
270    // so INSERT to a typed column doesn't have to go through the
271    // TextArray fallback + coerce chain.
272    if let Some(arr) = widen_uniform_typed(&items) {
273        return arr.build(items);
274    }
275    let mut has_text = false;
276    let mut has_bigint = false;
277    let mut has_int = false;
278    for v in &items {
279        match v {
280            Value::Null => {}
281            Value::Text(_) | Value::Json(_) => has_text = true,
282            Value::BigInt(_) => has_bigint = true,
283            Value::Int(_) | Value::SmallInt(_) => has_int = true,
284            _ => has_text = true,
285        }
286    }
287    if has_text || (!has_bigint && !has_int) {
288        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
289            .into_iter()
290            .map(|v| match v {
291                Value::Null => None,
292                Value::Text(s) | Value::Json(s) => Some(s.into_owned()),
293                other => Some(alloc::format!("{other:?}")),
294            })
295            .collect();
296        return Value::TextArray(out);
297    }
298    if has_bigint {
299        let out: alloc::vec::Vec<Option<i64>> = items
300            .into_iter()
301            .map(|v| match v {
302                Value::Null => None,
303                Value::Int(n) => Some(i64::from(n)),
304                Value::SmallInt(n) => Some(i64::from(n)),
305                Value::BigInt(n) => Some(n),
306                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
307            })
308            .collect();
309        return Value::BigIntArray(out);
310    }
311    let out: alloc::vec::Vec<Option<i32>> = items
312        .into_iter()
313        .map(|v| match v {
314            Value::Null => None,
315            Value::Int(n) => Some(n),
316            Value::SmallInt(n) => Some(i32::from(n)),
317            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
318        })
319        .collect();
320    Value::IntArray(out)
321}
322
323pub(crate) fn decode_text_array_literal(
324    s: &str,
325) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
326    let trimmed = s.trim();
327    let inner = trimmed
328        .strip_prefix('{')
329        .and_then(|x| x.strip_suffix('}'))
330        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
331    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
332    if inner.trim().is_empty() {
333        return Ok(out);
334    }
335    let bytes = inner.as_bytes();
336    let mut i = 0;
337    while i <= bytes.len() {
338        // Skip leading whitespace.
339        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
340            i += 1;
341        }
342        // Quoted element.
343        if i < bytes.len() && bytes[i] == b'"' {
344            i += 1; // open quote
345            let mut buf = alloc::string::String::new();
346            while i < bytes.len() && bytes[i] != b'"' {
347                if bytes[i] == b'\\' && i + 1 < bytes.len() {
348                    buf.push(bytes[i + 1] as char);
349                    i += 2;
350                } else {
351                    buf.push(bytes[i] as char);
352                    i += 1;
353                }
354            }
355            if i >= bytes.len() {
356                return Err("unterminated quoted element");
357            }
358            i += 1; // close quote
359            out.push(Some(buf));
360        } else {
361            // Unquoted element — read until next comma or end.
362            let start = i;
363            while i < bytes.len() && bytes[i] != b',' {
364                i += 1;
365            }
366            let raw = inner[start..i].trim();
367            if raw.eq_ignore_ascii_case("NULL") {
368                out.push(None);
369            } else {
370                out.push(Some(alloc::string::ToString::to_string(raw)));
371            }
372        }
373        // Skip whitespace, expect comma or end.
374        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
375            i += 1;
376        }
377        if i >= bytes.len() {
378            break;
379        }
380        if bytes[i] != b',' {
381            return Err("expected ',' between TEXT[] elements");
382        }
383        i += 1;
384    }
385    Ok(out)
386}
387
388/// v7.10.11 — encode a TEXT[] back into the PG external array
389/// form. NULL elements become the literal `NULL`; elements
390/// containing commas, quotes, backslashes, or braces are
391/// double-quoted with `\\` / `\"` escapes.
392pub(crate) fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
393    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
394    out.push('{');
395    for (i, item) in items.iter().enumerate() {
396        if i > 0 {
397            out.push(',');
398        }
399        match item {
400            None => out.push_str("NULL"),
401            Some(s) => {
402                let needs_quote = s.is_empty()
403                    || s.eq_ignore_ascii_case("NULL")
404                    || s.chars()
405                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
406                if needs_quote {
407                    out.push('"');
408                    for c in s.chars() {
409                        if c == '"' || c == '\\' {
410                            out.push('\\');
411                        }
412                        out.push(c);
413                    }
414                    out.push('"');
415                } else {
416                    out.push_str(s);
417                }
418            }
419        }
420    }
421    out.push('}');
422    out
423}
424
425/// v7.10.4 — encode BYTEA bytes in PG hex output format
426/// (`\x` prefix, lowercase hex pairs). Used by Text-side
427/// round-trip + the wire layer's text-mode encoder.
428pub(crate) fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
429    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
430    out.push_str("\\x");
431    for byte in b {
432        let hi = byte >> 4;
433        let lo = byte & 0x0F;
434        out.push(hex_digit(hi));
435        out.push(hex_digit(lo));
436    }
437    out
438}
439
440pub(crate) const fn hex_digit(n: u8) -> char {
441    match n {
442        0..=9 => (b'0' + n) as char,
443        10..=15 => (b'a' + n - 10) as char,
444        _ => '?',
445    }
446}
447
448/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
449/// a flat key→value map. Empty string → empty map. Duplicate
450/// keys take last-write-wins (matches PG `hstore_in`).
451///
452/// Accepted shapes (minimal subset):
453///   * `'a=>1, b=>2'`            — bareword keys/values
454///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
455///   * `'a=>NULL'`               — case-insensitive NULL token
456///     surfaces as `None` (no quotes around NULL)
457///
458/// Returns None on parse failure → caller surfaces as hard error.
459pub(crate) fn parse_hstore_str(
460    s: &str,
461) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
462    let bytes = s.as_bytes();
463    let mut i = 0;
464    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
465    let skip_ws = |bytes: &[u8], i: &mut usize| {
466        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
467            *i += 1;
468        }
469    };
470    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
471        if *i >= bytes.len() {
472            return None;
473        }
474        if bytes[*i] == b'"' {
475            *i += 1;
476            let mut out = alloc::string::String::new();
477            while *i < bytes.len() {
478                match bytes[*i] {
479                    b'"' => {
480                        *i += 1;
481                        return Some(out);
482                    }
483                    b'\\' if *i + 1 < bytes.len() => {
484                        out.push(bytes[*i + 1] as char);
485                        *i += 2;
486                    }
487                    c => {
488                        out.push(c as char);
489                        *i += 1;
490                    }
491                }
492            }
493            None
494        } else {
495            let start = *i;
496            while *i < bytes.len()
497                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
498            {
499                *i += 1;
500            }
501            if *i == start {
502                return None;
503            }
504            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
505        }
506    };
507    skip_ws(bytes, &mut i);
508    while i < bytes.len() {
509        let key = parse_token(bytes, &mut i)?;
510        skip_ws(bytes, &mut i);
511        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
512            return None;
513        }
514        i += 2;
515        skip_ws(bytes, &mut i);
516        // Check for unquoted NULL token (case-insensitive).
517        let val_token = if i + 4 <= bytes.len()
518            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
519            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
520        {
521            i += 4;
522            None
523        } else {
524            Some(parse_token(bytes, &mut i)?)
525        };
526        // Replace any existing entry with the same key (last-wins).
527        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
528            out[pos] = (key, val_token);
529        } else {
530            out.push((key, val_token));
531        }
532        skip_ws(bytes, &mut i);
533        if i >= bytes.len() {
534            break;
535        }
536        if bytes[i] == b',' {
537            i += 1;
538            skip_ws(bytes, &mut i);
539            continue;
540        }
541        return None;
542    }
543    Some(out)
544}
545
546/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
547/// form `"k"=>"v"` (keys and non-NULL values always quoted;
548/// NULL token is bare).
549pub(crate) fn format_hstore_str(
550    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
551) -> alloc::string::String {
552    let mut out = alloc::string::String::new();
553    for (i, (k, v)) in pairs.iter().enumerate() {
554        if i > 0 {
555            out.push_str(", ");
556        }
557        out.push('"');
558        out.push_str(k);
559        out.push_str("\"=>");
560        match v {
561            None => out.push_str("NULL"),
562            Some(val) => {
563                out.push('"');
564                out.push_str(val);
565                out.push('"');
566            }
567        }
568    }
569    out
570}
571
572/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
573/// share the single hstore renderer.
574pub fn format_hstore_text(
575    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
576) -> alloc::string::String {
577    format_hstore_str(pairs)
578}
579
580// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
581
582/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
583/// per-row token lists. Returns Err on shape mismatch.
584pub(crate) fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
585    let s = s.trim();
586    let outer = s
587        .strip_prefix('{')
588        .and_then(|x| x.strip_suffix('}'))
589        .ok_or("missing outer '{...}' braces")?;
590    let trimmed = outer.trim();
591    if trimmed.is_empty() {
592        return Ok(Vec::new());
593    }
594    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
595    let mut i = 0;
596    let bytes = trimmed.as_bytes();
597    while i < bytes.len() {
598        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
599            i += 1;
600        }
601        if i >= bytes.len() {
602            break;
603        }
604        if bytes[i] != b'{' {
605            return Err("expected '{' opening a row");
606        }
607        i += 1;
608        let row_start = i;
609        let mut depth = 1;
610        while i < bytes.len() && depth > 0 {
611            match bytes[i] {
612                b'{' => depth += 1,
613                b'}' => depth -= 1,
614                _ => {}
615            }
616            if depth > 0 {
617                i += 1;
618            }
619        }
620        if depth != 0 {
621            return Err("unbalanced '{...}' in row");
622        }
623        let row_text = &trimmed[row_start..i];
624        i += 1;
625        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
626            Vec::new()
627        } else {
628            row_text.split(',').map(|t| t.trim().to_string()).collect()
629        };
630        rows.push(cells);
631    }
632    if let Some(first) = rows.first() {
633        let cols = first.len();
634        for r in &rows {
635            if r.len() != cols {
636                return Err("ragged 2D array (rows have different column counts)");
637            }
638        }
639    }
640    Ok(rows)
641}
642
643pub(crate) fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
644    let raw = split_2d_literal(s)?;
645    raw.into_iter()
646        .map(|row| {
647            row.into_iter()
648                .map(|cell| {
649                    if cell.eq_ignore_ascii_case("NULL") {
650                        Ok(None)
651                    } else {
652                        cell.parse::<i32>()
653                            .map(Some)
654                            .map_err(|_| "invalid int element")
655                    }
656                })
657                .collect()
658        })
659        .collect()
660}
661
662pub(crate) fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
663    let raw = split_2d_literal(s)?;
664    raw.into_iter()
665        .map(|row| {
666            row.into_iter()
667                .map(|cell| {
668                    if cell.eq_ignore_ascii_case("NULL") {
669                        Ok(None)
670                    } else {
671                        cell.parse::<i64>()
672                            .map(Some)
673                            .map_err(|_| "invalid bigint element")
674                    }
675                })
676                .collect()
677        })
678        .collect()
679}
680
681pub(crate) fn parse_text_2d_literal(
682    s: &str,
683) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
684    let raw = split_2d_literal(s)?;
685    Ok(raw
686        .into_iter()
687        .map(|row| {
688            row.into_iter()
689                .map(|cell| {
690                    if cell.eq_ignore_ascii_case("NULL") {
691                        None
692                    } else {
693                        Some(cell.trim_matches('"').to_string())
694                    }
695                })
696                .collect()
697        })
698        .collect())
699}
700
701pub(crate) fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
702    let mut out = alloc::string::String::from("{");
703    for (i, row) in rows.iter().enumerate() {
704        if i > 0 {
705            out.push(',');
706        }
707        out.push('{');
708        for (j, cell) in row.iter().enumerate() {
709            if j > 0 {
710                out.push(',');
711            }
712            match cell {
713                None => out.push_str("NULL"),
714                Some(n) => out.push_str(&alloc::format!("{n}")),
715            }
716        }
717        out.push('}');
718    }
719    out.push('}');
720    out
721}
722
723pub(crate) fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
724    let mut out = alloc::string::String::from("{");
725    for (i, row) in rows.iter().enumerate() {
726        if i > 0 {
727            out.push(',');
728        }
729        out.push('{');
730        for (j, cell) in row.iter().enumerate() {
731            if j > 0 {
732                out.push(',');
733            }
734            match cell {
735                None => out.push_str("NULL"),
736                Some(n) => out.push_str(&alloc::format!("{n}")),
737            }
738        }
739        out.push('}');
740    }
741    out.push('}');
742    out
743}
744
745pub(crate) fn format_text_2d_text(
746    rows: &[Vec<Option<alloc::string::String>>],
747) -> alloc::string::String {
748    let mut out = alloc::string::String::from("{");
749    for (i, row) in rows.iter().enumerate() {
750        if i > 0 {
751            out.push(',');
752        }
753        out.push('{');
754        for (j, cell) in row.iter().enumerate() {
755            if j > 0 {
756                out.push(',');
757            }
758            match cell {
759                None => out.push_str("NULL"),
760                Some(s) => out.push_str(s),
761            }
762        }
763        out.push('}');
764    }
765    out.push('}');
766    out
767}
768
769/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
770/// share the single 2D-array renderer.
771pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
772    format_int_2d_text(rows)
773}
774pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
775    format_bigint_2d_text(rows)
776}
777pub fn format_text_2d_text_pub(
778    rows: &[Vec<Option<alloc::string::String>>],
779) -> alloc::string::String {
780    format_text_2d_text(rows)
781}
782
783/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
784/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
785/// `'empty'`. Lower / upper may be empty (unbounded). Returns
786/// `None` on any parse failure; caller surfaces as hard error.
787pub(crate) fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value<'static>> {
788    let s = s.trim();
789    if s.eq_ignore_ascii_case("empty") {
790        return Some(Value::Range {
791            kind,
792            lower: None,
793            upper: None,
794            lower_inc: false,
795            upper_inc: false,
796            empty: true,
797        });
798    }
799    let bytes = s.as_bytes();
800    if bytes.len() < 3 {
801        return None;
802    }
803    let lower_inc = match bytes[0] {
804        b'[' => true,
805        b'(' => false,
806        _ => return None,
807    };
808    let upper_inc = match bytes[bytes.len() - 1] {
809        b']' => true,
810        b')' => false,
811        _ => return None,
812    };
813    let inner = &s[1..s.len() - 1];
814    let (lo_text, up_text) = inner.split_once(',')?;
815    let lower = if lo_text.is_empty() {
816        None
817    } else {
818        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
819    };
820    let upper = if up_text.is_empty() {
821        None
822    } else {
823        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
824    };
825    Some(Value::Range {
826        kind,
827        lower,
828        upper,
829        lower_inc,
830        upper_inc,
831        empty: false,
832    })
833}
834
835/// v7.37.5 δ — parse a PG multirange external form into a Vec of
836/// `RangeSpan`. Grammar: `{}` empty, `{range1,range2,...}` with
837/// each range in canonical `[/(/]/)` brackets. Empty subranges
838/// (`empty`) are accepted but get dropped on round-trip per PG
839/// semantics. The bounds parser reuses `parse_range_str` by
840/// wrapping each subrange in the parent kind.
841pub(crate) fn parse_multirange_str(
842    s: &str,
843    kind: spg_storage::RangeKind,
844) -> Option<Vec<spg_storage::RangeSpan>> {
845    let s = s.trim();
846    let inner = s.strip_prefix('{').and_then(|x| x.strip_suffix('}'))?;
847    let inner = inner.trim();
848    if inner.is_empty() {
849        return Some(Vec::new());
850    }
851    // Split the inner on commas that sit *between* ranges — not the
852    // commas inside `[a,b)`. Walk depth: bump on `[` / `(`, drop on
853    // `]` / `)`. Commas at depth 0 are range separators.
854    let mut spans: Vec<spg_storage::RangeSpan> = Vec::new();
855    let bytes = inner.as_bytes();
856    let mut depth: i32 = 0;
857    let mut start = 0usize;
858    for i in 0..=bytes.len() {
859        let cut = i == bytes.len() || (depth == 0 && bytes[i] == b',');
860        if !cut {
861            match bytes.get(i) {
862                Some(b'[') | Some(b'(') => depth += 1,
863                Some(b']') | Some(b')') => depth -= 1,
864                _ => {}
865            }
866            continue;
867        }
868        let piece = inner[start..i].trim();
869        if piece.is_empty() {
870            return None;
871        }
872        let r = parse_range_str(piece, kind)?;
873        let Value::Range {
874            lower,
875            upper,
876            lower_inc,
877            upper_inc,
878            empty,
879            ..
880        } = r
881        else {
882            return None;
883        };
884        spans.push(spg_storage::RangeSpan {
885            lower,
886            upper,
887            lower_inc,
888            upper_inc,
889            empty,
890        });
891        start = i + 1;
892    }
893    Some(spans)
894}
895
896/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
897/// the matching element Value for the RangeKind.
898pub(crate) fn parse_range_element(
899    text: &str,
900    kind: spg_storage::RangeKind,
901) -> Option<Value<'static>> {
902    let text = text.trim().trim_matches('"');
903    use spg_storage::RangeKind as K;
904    match kind {
905        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
906        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
907        K::Num => {
908            // Reuse the Numeric parse via the engine's text-coercion
909            // path; bail to None on failure.
910            let dot = text.find('.');
911            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
912            let digits: alloc::string::String = text
913                .chars()
914                .filter(|c| *c == '-' || c.is_ascii_digit())
915                .collect();
916            let scaled: i128 = digits.parse().ok()?;
917            Some(Value::Numeric { scaled, scale })
918        }
919        K::Ts | K::TsTz => {
920            // Reuse the existing timestamp parse path. v7.17.0
921            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
922            // bounds (TZ offset on TsTz is OOS for the initial
923            // P0-38; ship plain Timestamp shape).
924            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
925        }
926        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
927    }
928}
929
930/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
931/// PG text form. Re-exported via [`format_range_text`] for use
932/// from spg-server's pgwire layer.
933pub fn format_range_text(v: &Value) -> alloc::string::String {
934    format_range_str(v)
935}
936
937pub(crate) fn format_range_str(v: &Value) -> alloc::string::String {
938    let Value::Range {
939        lower,
940        upper,
941        lower_inc,
942        upper_inc,
943        empty,
944        ..
945    } = v
946    else {
947        return alloc::string::String::new();
948    };
949    if *empty {
950        return "empty".into();
951    }
952    let mut out = alloc::string::String::new();
953    out.push(if *lower_inc { '[' } else { '(' });
954    if let Some(l) = lower {
955        out.push_str(&format_range_element(l));
956    }
957    out.push(',');
958    if let Some(u) = upper {
959        out.push_str(&format_range_element(u));
960    }
961    out.push(if *upper_inc { ']' } else { ')' });
962    out
963}
964
965/// v7.37.5 ε — render a Point as PG canonical `(x,y)`.
966pub fn format_point(p: spg_storage::Point2D) -> alloc::string::String {
967    alloc::format!("({},{})", p.x, p.y)
968}
969
970/// v7.37.5 ε — render an Lseg as PG canonical `[(x1,y1),(x2,y2)]`.
971pub fn format_lseg(p1: spg_storage::Point2D, p2: spg_storage::Point2D) -> alloc::string::String {
972    alloc::format!("[({},{}),({},{})]", p1.x, p1.y, p2.x, p2.y)
973}
974
975/// v7.37.5 ε — render a Box as PG canonical `(ux,uy),(lx,ly)`.
976/// PG normalises the corner order on input; we trust the engine's
977/// constructor has already normalised so the field order here is
978/// the canonical upper-right + lower-left.
979pub fn format_pg_box(ur: spg_storage::Point2D, ll: spg_storage::Point2D) -> alloc::string::String {
980    alloc::format!("({},{}),({},{})", ur.x, ur.y, ll.x, ll.y)
981}
982
983/// v7.37.5 ε — render a Line as PG canonical `{a,b,c}` (Ax+By+C=0).
984pub fn format_line(a: f64, b: f64, c: f64) -> alloc::string::String {
985    alloc::format!("{{{},{},{}}}", a, b, c)
986}
987
988/// v7.37.5 ε — render a Circle as PG canonical `<(x,y),r>`.
989pub fn format_circle(center: spg_storage::Point2D, radius: f64) -> alloc::string::String {
990    alloc::format!("<({},{}),{}>", center.x, center.y, radius)
991}
992
993/// v7.37.5 ε — render a Path as PG canonical `[(x,y),...]` open
994/// or `((x,y),...)` closed.
995pub fn format_path(points: &[spg_storage::Point2D], closed: bool) -> alloc::string::String {
996    let (open, close) = if closed { ('(', ')') } else { ('[', ']') };
997    let mut out = alloc::string::String::new();
998    out.push(open);
999    for (i, p) in points.iter().enumerate() {
1000        if i > 0 {
1001            out.push(',');
1002        }
1003        out.push_str(&alloc::format!("({},{})", p.x, p.y));
1004    }
1005    out.push(close);
1006    out
1007}
1008
1009/// v7.37.5 ε — render a Polygon as PG canonical `((x,y),...)`.
1010pub fn format_polygon(points: &[spg_storage::Point2D]) -> alloc::string::String {
1011    let mut out = alloc::string::String::new();
1012    out.push('(');
1013    for (i, p) in points.iter().enumerate() {
1014        if i > 0 {
1015            out.push(',');
1016        }
1017        out.push_str(&alloc::format!("({},{})", p.x, p.y));
1018    }
1019    out.push(')');
1020    out
1021}
1022
1023/// v7.37.5 ε — parse a single `(x,y)` or bare `x,y` Point text.
1024/// Surrounding whitespace OK. Returns `None` on malformed input.
1025fn parse_point(s: &str) -> Option<spg_storage::Point2D> {
1026    let s = s.trim();
1027    let inner = s
1028        .strip_prefix('(')
1029        .and_then(|x| x.strip_suffix(')'))
1030        .unwrap_or(s);
1031    let (xs, ys) = inner.split_once(',')?;
1032    let x: f64 = xs.trim().parse().ok()?;
1033    let y: f64 = ys.trim().parse().ok()?;
1034    Some(spg_storage::Point2D { x, y })
1035}
1036
1037/// v7.37.5 ε — parse N points from a comma-separated PG point
1038/// list (`(x1,y1),(x2,y2),...`). Depth-aware split so the commas
1039/// inside each `(...)` aren't taken as separators. Returns `None`
1040/// on malformed input.
1041fn parse_point_list(s: &str) -> Option<Vec<spg_storage::Point2D>> {
1042    let bytes = s.as_bytes();
1043    let mut out: Vec<spg_storage::Point2D> = Vec::new();
1044    let mut depth: i32 = 0;
1045    let mut start = 0usize;
1046    for i in 0..=bytes.len() {
1047        let cut = i == bytes.len() || (depth == 0 && bytes[i] == b',');
1048        if !cut {
1049            match bytes.get(i) {
1050                Some(b'(') | Some(b'[') | Some(b'<') => depth += 1,
1051                Some(b')') | Some(b']') | Some(b'>') => depth -= 1,
1052                _ => {}
1053            }
1054            continue;
1055        }
1056        let piece = s[start..i].trim();
1057        if !piece.is_empty() {
1058            out.push(parse_point(piece)?);
1059        }
1060        start = i + 1;
1061    }
1062    Some(out)
1063}
1064
1065/// v7.37.5 ε — parse Lseg text `[(x1,y1),(x2,y2)]`.
1066pub fn parse_lseg_text(s: &str) -> Option<(spg_storage::Point2D, spg_storage::Point2D)> {
1067    let s = s.trim();
1068    let inner = s.strip_prefix('[').and_then(|x| x.strip_suffix(']'))?;
1069    let pts = parse_point_list(inner)?;
1070    if pts.len() != 2 {
1071        return None;
1072    }
1073    Some((pts[0], pts[1]))
1074}
1075
1076/// v7.37.5 ε — parse Box text `(ux,uy),(lx,ly)`. PG normalises
1077/// any two-corner input into upper-right + lower-left; we do
1078/// the same.
1079pub fn parse_box_text(s: &str) -> Option<(spg_storage::Point2D, spg_storage::Point2D)> {
1080    let pts = parse_point_list(s)?;
1081    if pts.len() != 2 {
1082        return None;
1083    }
1084    let (a, b) = (pts[0], pts[1]);
1085    // Normalise: upper-right has the larger x AND larger y.
1086    let ur = spg_storage::Point2D {
1087        x: a.x.max(b.x),
1088        y: a.y.max(b.y),
1089    };
1090    let ll = spg_storage::Point2D {
1091        x: a.x.min(b.x),
1092        y: a.y.min(b.y),
1093    };
1094    Some((ur, ll))
1095}
1096
1097/// v7.37.5 ε — parse Line text `{a,b,c}`.
1098pub fn parse_line_text(s: &str) -> Option<(f64, f64, f64)> {
1099    let s = s.trim();
1100    let inner = s.strip_prefix('{').and_then(|x| x.strip_suffix('}'))?;
1101    let parts: Vec<&str> = inner.split(',').collect();
1102    if parts.len() != 3 {
1103        return None;
1104    }
1105    let a: f64 = parts[0].trim().parse().ok()?;
1106    let b: f64 = parts[1].trim().parse().ok()?;
1107    let c: f64 = parts[2].trim().parse().ok()?;
1108    Some((a, b, c))
1109}
1110
1111/// v7.37.5 ε — parse Circle text `<(x,y),r>` or `((x,y),r)`.
1112pub fn parse_circle_text(s: &str) -> Option<(spg_storage::Point2D, f64)> {
1113    let s = s.trim();
1114    let inner = if let Some(i) = s.strip_prefix('<').and_then(|x| x.strip_suffix('>')) {
1115        i
1116    } else {
1117        s.strip_prefix('(').and_then(|x| x.strip_suffix(')'))?
1118    };
1119    // The last comma at depth 0 splits the center from the radius.
1120    let bytes = inner.as_bytes();
1121    let mut depth = 0i32;
1122    let mut split_at: Option<usize> = None;
1123    for (i, &b) in bytes.iter().enumerate() {
1124        match b {
1125            b'(' | b'[' | b'<' => depth += 1,
1126            b')' | b']' | b'>' => depth -= 1,
1127            b',' if depth == 0 => split_at = Some(i),
1128            _ => {}
1129        }
1130    }
1131    let i = split_at?;
1132    let center = parse_point(&inner[..i])?;
1133    let radius: f64 = inner[i + 1..].trim().parse().ok()?;
1134    Some((center, radius))
1135}
1136
1137/// v7.37.5 ε — parse Path text `[(x,y),...]` (open) or
1138/// `((x,y),...)` (closed). The leading bracket pins openness.
1139pub fn parse_path_text(s: &str) -> Option<(Vec<spg_storage::Point2D>, bool)> {
1140    let s = s.trim();
1141    let (closed, inner) = if let Some(i) = s.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
1142        (false, i)
1143    } else if let Some(i) = s.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
1144        (true, i)
1145    } else {
1146        return None;
1147    };
1148    let pts = parse_point_list(inner)?;
1149    Some((pts, closed))
1150}
1151
1152/// v7.37.5 ε — parse Polygon text `((x,y),...)` (implicit closed).
1153pub fn parse_polygon_text(s: &str) -> Option<Vec<spg_storage::Point2D>> {
1154    let s = s.trim();
1155    let inner = s.strip_prefix('(').and_then(|x| x.strip_suffix(')'))?;
1156    parse_point_list(inner)
1157}
1158
1159/// v7.37.5 ζ-A — render an INET/CIDR address as canonical PG text:
1160/// IPv4: `a.b.c.d/bits`; IPv6: `xxxx:xxxx:.../bits`. The mask is
1161/// elided when it equals the family default (32 for IPv4, 128 for
1162/// IPv6), per PG convention.
1163pub fn format_inet(family: u8, bits: u8, addr: &[u8; 16]) -> alloc::string::String {
1164    match family {
1165        4 => {
1166            let s = alloc::format!("{}.{}.{}.{}", addr[0], addr[1], addr[2], addr[3]);
1167            if bits == 32 {
1168                s
1169            } else {
1170                alloc::format!("{s}/{bits}")
1171            }
1172        }
1173        6 => {
1174            // Naive `xxxx:xxxx:...` colon-separated form. PG's
1175            // `::` compression is a follow-up; the canonical text
1176            // here still round-trips correctly through `parse_inet`.
1177            let mut out = alloc::string::String::new();
1178            for i in 0..8 {
1179                if i > 0 {
1180                    out.push(':');
1181                }
1182                let hi = addr[i * 2];
1183                let lo = addr[i * 2 + 1];
1184                let word = (u16::from(hi) << 8) | u16::from(lo);
1185                out.push_str(&alloc::format!("{word:x}"));
1186            }
1187            if bits == 128 {
1188                out
1189            } else {
1190                alloc::format!("{out}/{bits}")
1191            }
1192        }
1193        _ => alloc::format!("?invalid-inet-family-{family}"),
1194    }
1195}
1196
1197/// v7.37.5 ζ-A — render a MACADDR (6 bytes) as `aa:bb:cc:dd:ee:ff`.
1198pub fn format_macaddr(m: &[u8; 6]) -> alloc::string::String {
1199    alloc::format!(
1200        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
1201        m[0],
1202        m[1],
1203        m[2],
1204        m[3],
1205        m[4],
1206        m[5]
1207    )
1208}
1209
1210/// v7.37.5 ζ-A — render a MACADDR8 (8 bytes) as `aa:bb:cc:dd:ee:ff:00:11`.
1211pub fn format_macaddr8(m: &[u8; 8]) -> alloc::string::String {
1212    alloc::format!(
1213        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
1214        m[0],
1215        m[1],
1216        m[2],
1217        m[3],
1218        m[4],
1219        m[5],
1220        m[6],
1221        m[7]
1222    )
1223}
1224
1225/// v7.37.5 ζ-A — render a BIT / BIT VARYING as a binary string of
1226/// `'0'` and `'1'` chars (PG canonical text form). Bytes are packed
1227/// big-endian within each byte: the most-significant bit of byte 0
1228/// is bit 0 of the bit string.
1229pub fn format_bit_string(nbits: u32, bytes: &[u8]) -> alloc::string::String {
1230    let mut out = alloc::string::String::with_capacity(nbits as usize);
1231    for i in 0..nbits as usize {
1232        let byte = bytes[i / 8];
1233        let bit = (byte >> (7 - (i % 8))) & 1;
1234        out.push(if bit == 1 { '1' } else { '0' });
1235    }
1236    out
1237}
1238
1239/// v7.37.5 ζ-A — render a MONEY[] in PG external form. Each element
1240/// is the canonical `format_money` output; the array wrapper is
1241/// `{...}` with NULL elements as the literal token `NULL`.
1242pub fn format_money_array(items: &[Option<i64>]) -> alloc::string::String {
1243    let mut out = alloc::string::String::new();
1244    out.push('{');
1245    for (i, item) in items.iter().enumerate() {
1246        if i > 0 {
1247            out.push(',');
1248        }
1249        match item {
1250            None => out.push_str("NULL"),
1251            Some(c) => out.push_str(&crate::eval::format_money(*c)),
1252        }
1253    }
1254    out.push('}');
1255    out
1256}
1257
1258/// v7.37.5 ζ-A — parse PG INET text. Accepts `a.b.c.d[/bits]`
1259/// (IPv4) or `xxxx:xxxx:.../[bits]` (IPv6 colon-separated). The
1260/// mask defaults to 32 (IPv4) / 128 (IPv6) when omitted. Returns
1261/// `(family, bits, addr16)`. `None` on malformed input.
1262pub fn parse_inet_text(s: &str) -> Option<(u8, u8, [u8; 16])> {
1263    let s = s.trim();
1264    let (addr_s, bits_s) = match s.split_once('/') {
1265        Some((a, b)) => (a, Some(b)),
1266        None => (s, None),
1267    };
1268    if addr_s.contains(':') {
1269        // IPv6 — colon-separated up to 8 × u16 hex with optional
1270        // `::` zero-compression. v7.37.5 ship triage broadened the
1271        // pre-7.37.10 8-group-only form to accept canonical PG
1272        // IPv6 abbreviations like `2001:db8::/32`.
1273        let (head, tail) = match addr_s.find("::") {
1274            Some(idx) => (&addr_s[..idx], Some(&addr_s[idx + 2..])),
1275            None => (addr_s, None),
1276        };
1277        let head_groups: alloc::vec::Vec<&str> = if head.is_empty() {
1278            alloc::vec::Vec::new()
1279        } else {
1280            head.split(':').collect()
1281        };
1282        let tail_groups: alloc::vec::Vec<&str> = match tail {
1283            Some(t) if !t.is_empty() => t.split(':').collect(),
1284            _ => alloc::vec::Vec::new(),
1285        };
1286        let head_len = head_groups.len();
1287        let tail_len = tail_groups.len();
1288        if tail.is_none() {
1289            if head_len != 8 {
1290                return None;
1291            }
1292        } else if head_len + tail_len > 7 {
1293            return None;
1294        }
1295        let mut words = [0u16; 8];
1296        for (i, g) in head_groups.iter().enumerate() {
1297            words[i] = u16::from_str_radix(g, 16).ok()?;
1298        }
1299        let tail_start = 8 - tail_len;
1300        for (i, g) in tail_groups.iter().enumerate() {
1301            words[tail_start + i] = u16::from_str_radix(g, 16).ok()?;
1302        }
1303        let mut addr = [0u8; 16];
1304        for (i, w) in words.iter().enumerate() {
1305            addr[i * 2] = (w >> 8) as u8;
1306            addr[i * 2 + 1] = (w & 0xff) as u8;
1307        }
1308        let bits = match bits_s {
1309            Some(b) => b.parse::<u8>().ok().filter(|&n| n <= 128)?,
1310            None => 128,
1311        };
1312        Some((6, bits, addr))
1313    } else {
1314        // IPv4 — `a.b.c.d`.
1315        let parts: alloc::vec::Vec<&str> = addr_s.split('.').collect();
1316        if parts.len() != 4 {
1317            return None;
1318        }
1319        let mut addr = [0u8; 16];
1320        for (i, p) in parts.iter().enumerate() {
1321            addr[i] = p.parse::<u8>().ok()?;
1322        }
1323        let bits = match bits_s {
1324            Some(b) => b.parse::<u8>().ok().filter(|&n| n <= 32)?,
1325            None => 32,
1326        };
1327        Some((4, bits, addr))
1328    }
1329}
1330
1331/// v7.37.5 ζ-A — parse PG MACADDR text `aa:bb:cc:dd:ee:ff` (also
1332/// accepts `aa-bb-cc-dd-ee-ff` and unseparated `aabbccddeeff`).
1333pub fn parse_macaddr_text(s: &str) -> Option<[u8; 6]> {
1334    let s = s.trim();
1335    let cleaned: alloc::string::String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1336    if cleaned.len() != 12 {
1337        return None;
1338    }
1339    let mut out = [0u8; 6];
1340    for i in 0..6 {
1341        out[i] = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).ok()?;
1342    }
1343    Some(out)
1344}
1345
1346/// v7.37.5 ζ-A — parse PG MACADDR8 text.
1347pub fn parse_macaddr8_text(s: &str) -> Option<[u8; 8]> {
1348    let s = s.trim();
1349    let cleaned: alloc::string::String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1350    if cleaned.len() != 16 {
1351        return None;
1352    }
1353    let mut out = [0u8; 8];
1354    for i in 0..8 {
1355        out[i] = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).ok()?;
1356    }
1357    Some(out)
1358}
1359
1360/// v7.37.5 ζ-A — parse PG bit string text (a sequence of `'0'` and
1361/// `'1'` chars). Returns `(nbits, packed_bytes)` — bytes are
1362/// big-endian within each byte (PG canonical).
1363pub fn parse_bit_string_text(s: &str) -> Option<(u32, alloc::vec::Vec<u8>)> {
1364    let s = s.trim();
1365    let nbits = u32::try_from(s.len()).ok()?;
1366    let nbytes = (s.len()).div_ceil(8);
1367    let mut bytes = alloc::vec![0u8; nbytes];
1368    for (i, c) in s.chars().enumerate() {
1369        let bit = match c {
1370            '0' => 0u8,
1371            '1' => 1u8,
1372            _ => return None,
1373        };
1374        if bit == 1 {
1375            bytes[i / 8] |= 1 << (7 - (i % 8));
1376        }
1377    }
1378    Some((nbits, bytes))
1379}
1380
1381/// v7.37.5 δ — render a Multirange in PG external form
1382/// `{[a,b),[c,d)}`. Empty multirange renders as `{}`. Each range
1383/// element is formatted with the same `[/(/]/)` bracket grammar
1384/// as scalar `Value::Range`. RangeSpan carries no `kind` (it
1385/// lives on the parent Multirange), so this routes element
1386/// formatting through `format_range_element` as Value::Range does.
1387pub fn format_multirange(ranges: &[spg_storage::RangeSpan]) -> alloc::string::String {
1388    let mut out = alloc::string::String::new();
1389    out.push('{');
1390    for (i, r) in ranges.iter().enumerate() {
1391        if i > 0 {
1392            out.push(',');
1393        }
1394        if r.empty {
1395            out.push_str("empty");
1396            continue;
1397        }
1398        out.push(if r.lower_inc { '[' } else { '(' });
1399        if let Some(l) = &r.lower {
1400            out.push_str(&format_range_element(l));
1401        }
1402        out.push(',');
1403        if let Some(u) = &r.upper {
1404            out.push_str(&format_range_element(u));
1405        }
1406        out.push(if r.upper_inc { ']' } else { ')' });
1407    }
1408    out.push('}');
1409    out
1410}
1411
1412pub(crate) fn format_range_element(v: &Value) -> alloc::string::String {
1413    match v {
1414        Value::Int(n) => alloc::format!("{n}"),
1415        Value::BigInt(n) => alloc::format!("{n}"),
1416        Value::Date(d) => crate::eval::format_date(*d),
1417        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
1418        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
1419        other => alloc::format!("{other:?}"),
1420    }
1421}
1422
1423/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
1424/// cents. Accepts:
1425///   * Optional leading `-` (negative)
1426///   * Optional `$` prefix
1427///   * Integer portion with optional `,` thousands separators
1428///   * Optional `.` followed by 1-2 digits (cents); 1 digit
1429///     auto-pads to 2 (`.5` → 50 cents).
1430///
1431/// Returns None on any parse failure — caller surfaces as hard
1432/// SQL error.
1433pub(crate) fn parse_money_str(s: &str) -> Option<i64> {
1434    let s = s.trim();
1435    let (neg, rest) = match s.strip_prefix('-') {
1436        Some(r) => (true, r.trim_start()),
1437        None => (false, s),
1438    };
1439    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
1440    let (int_part, frac_part) = match rest.split_once('.') {
1441        Some((i, f)) => (i, Some(f)),
1442        None => (rest, None),
1443    };
1444    if int_part.is_empty() {
1445        return None;
1446    }
1447    // Validate + strip commas from the integer portion.
1448    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
1449    for b in int_part.bytes() {
1450        match b {
1451            b',' => {}
1452            b'0'..=b'9' => int_digits.push(b as char),
1453            _ => return None,
1454        }
1455    }
1456    if int_digits.is_empty() {
1457        return None;
1458    }
1459    let dollars: i64 = int_digits.parse().ok()?;
1460    let cents: i64 = match frac_part {
1461        None => 0,
1462        Some(f) => {
1463            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
1464                return None;
1465            }
1466            let padded = if f.len() == 1 {
1467                alloc::format!("{f}0")
1468            } else {
1469                f.to_string()
1470            };
1471            padded.parse().ok()?
1472        }
1473    };
1474    let total = dollars.checked_mul(100)?.checked_add(cents)?;
1475    Some(if neg { -total } else { total })
1476}
1477
1478/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
1479/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
1480///
1481/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
1482/// wired into eval, so a bare `HH:MM:SS` literal would be
1483/// ambiguous. Returns None for any parse failure or out-of-range
1484/// component — caller surfaces as a hard SQL error.
1485///
1486/// Offset range: ±14 hours (±50400 seconds), matching PG's
1487/// internal limit.
1488pub(crate) fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
1489    let s = s.trim();
1490    // Find the offset sign — scan from right since the time part
1491    // never contains '+' / '-' (after the optional fractional dot
1492    // it's all digits and ':').
1493    let bytes = s.as_bytes();
1494    let sign_pos = bytes
1495        .iter()
1496        .enumerate()
1497        .rev()
1498        .find(|&(_, &b)| b == b'+' || b == b'-')
1499        .map(|(i, _)| i)?;
1500    if sign_pos == 0 {
1501        return None; // bare sign — no time component
1502    }
1503    let time_part = &s[..sign_pos];
1504    let offset_part = &s[sign_pos..];
1505    let us = parse_time_str(time_part)?;
1506    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
1507    let offset_body = &offset_part[1..];
1508    let (hh_str, mm_str) = match offset_body.split_once(':') {
1509        Some((h, m)) => (h, m),
1510        None => (offset_body, "0"),
1511    };
1512    let hh: i32 = hh_str.parse().ok()?;
1513    let mm: i32 = mm_str.parse().ok()?;
1514    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
1515        return None;
1516    }
1517    let total = sign * (hh * 3600 + mm * 60);
1518    if total.abs() > 50_400 {
1519        return None;
1520    }
1521    Some((us, total))
1522}
1523
1524/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
1525/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
1526/// surfaces as a hard SQL error (no silent truncation, mirrors PG
1527/// `time_in` / `uuid_in` discipline).
1528pub(crate) fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value<'static>, EngineError> {
1529    if n == 0 || (1901..=2155).contains(&n) {
1530        // u16::try_from cannot fail in this range; the cast also
1531        // covers the 0 sentinel.
1532        return Ok(Value::Year(n as u16));
1533    }
1534    Err(EngineError::Eval(EvalError::TypeMismatch {
1535        detail: alloc::format!(
1536            "year value out of range: {n} (column `{col_name}`; \
1537             MySQL accepts 0 or 1901..=2155)"
1538        ),
1539    }))
1540}
1541
1542/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
1543/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
1544///
1545/// Accepts:
1546///   * `HH:MM:SS`            — exact-second precision
1547///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
1548///     with zeros to microseconds
1549///
1550/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
1551/// returns None — caller surfaces as a hard SQL error (no silent
1552/// truncation, matches PG's `time_in` behaviour).
1553pub(crate) fn parse_time_str(s: &str) -> Option<i64> {
1554    let s = s.trim();
1555    let (hms, frac) = match s.split_once('.') {
1556        Some((h, f)) => (h, Some(f)),
1557        None => (s, None),
1558    };
1559    let mut parts = hms.split(':');
1560    let hh: u32 = parts.next()?.parse().ok()?;
1561    let mm: u32 = parts.next()?.parse().ok()?;
1562    let ss: u32 = parts.next()?.parse().ok()?;
1563    if parts.next().is_some() {
1564        return None;
1565    }
1566    if hh > 23 || mm > 59 || ss > 59 {
1567        return None;
1568    }
1569    let frac_us: i64 = match frac {
1570        None => 0,
1571        Some(f) => {
1572            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
1573                return None;
1574            }
1575            // Right-pad with zeros so '.5' = 500000 µsec.
1576            let mut padded = alloc::string::String::with_capacity(6);
1577            padded.push_str(f);
1578            while padded.len() < 6 {
1579                padded.push('0');
1580            }
1581            padded.parse().ok()?
1582        }
1583    };
1584    Some(
1585        i64::from(hh) * 3_600_000_000
1586            + i64::from(mm) * 60_000_000
1587            + i64::from(ss) * 1_000_000
1588            + frac_us,
1589    )
1590}
1591
1592/// v7.37.5 ship triage — string-form PG type name → `DataType`
1593/// lookup driving `CastTarget::Named` (the generic typed-cast
1594/// escape). Covers the v7.37.5 γ/δ/ε/ζ-A type-completeness work
1595/// that landed without per-type CastTarget variants. Returns
1596/// `None` for genuinely-unknown idents so the caller can surface
1597/// the existing "unsupported cast target" error.
1598pub(crate) fn type_name_to_data_type(name: &str) -> Option<DataType> {
1599    let n = name.trim().to_ascii_lowercase();
1600    // v7.37.5 ship triage — `numeric(p,s)` precision/scale params:
1601    // peel them off and route to a precision-bearing DataType.
1602    if let Some((head, paren)) = n.split_once('(')
1603        && let Some(args) = paren.strip_suffix(')')
1604    {
1605        let nums: alloc::vec::Vec<u8> = args
1606            .split(',')
1607            .map(|s| s.trim().parse::<u8>().unwrap_or(0))
1608            .collect();
1609        match head {
1610            "numeric" | "decimal" => {
1611                let precision = nums.first().copied().unwrap_or(0);
1612                let scale = nums.get(1).copied().unwrap_or(0);
1613                return Some(DataType::Numeric { precision, scale });
1614            }
1615            // `varchar(n)` / `char(n)` carry length caps; SPG stores
1616            // these as DataType::Varchar / Char(n). v7.37.5 cast
1617            // recognises both but the cast itself drops the cap
1618            // (Text widening at value time honours the per-row
1619            // length contract already in coerce_value).
1620            "varchar" => {
1621                return Some(DataType::Varchar(nums.first().copied().unwrap_or(0).into()));
1622            }
1623            "char" | "character" => {
1624                return Some(DataType::Char(nums.first().copied().unwrap_or(0).into()));
1625            }
1626            // bit / varbit precision drops on cast — storage shape
1627            // is the BitString regardless.
1628            "bit" => return Some(DataType::Bit),
1629            "varbit" => return Some(DataType::BitVarying),
1630            _ => {}
1631        }
1632    }
1633    Some(match n.as_str() {
1634        "smallint" | "int2" => DataType::SmallInt,
1635        "numeric" | "decimal" => DataType::Numeric {
1636            precision: 0,
1637            scale: 0,
1638        },
1639        // Network/MAC/bit/XML/"char" — all first-class since
1640        // v7.37.5 ζ-A.
1641        "inet" => DataType::Inet,
1642        "cidr" => DataType::Cidr,
1643        "macaddr" => DataType::Macaddr,
1644        "macaddr8" => DataType::Macaddr8,
1645        "bit" => DataType::Bit,
1646        "varbit" | "bit varying" => DataType::BitVarying,
1647        "xml" => DataType::Xml,
1648        "money" => DataType::Money,
1649        "char1" => DataType::Char1,
1650        // Geometry (v7.37.5 ε).
1651        "point" => DataType::Point,
1652        "lseg" => DataType::Lseg,
1653        "path" => DataType::Path,
1654        "box" => DataType::PgBox,
1655        "polygon" => DataType::Polygon,
1656        "line" => DataType::Line,
1657        "circle" => DataType::Circle,
1658        // Multirange (v7.37.5 δ).
1659        "int4multirange" => DataType::Multirange(spg_storage::RangeKind::Int4),
1660        "int8multirange" => DataType::Multirange(spg_storage::RangeKind::Int8),
1661        "nummultirange" => DataType::Multirange(spg_storage::RangeKind::Num),
1662        "tsmultirange" => DataType::Multirange(spg_storage::RangeKind::Ts),
1663        "tstzmultirange" => DataType::Multirange(spg_storage::RangeKind::TsTz),
1664        "datemultirange" => DataType::Multirange(spg_storage::RangeKind::Date),
1665        // Range scalars(scaffolded in v7.17, casts join here).
1666        "int4range" => DataType::Range(spg_storage::RangeKind::Int4),
1667        "int8range" => DataType::Range(spg_storage::RangeKind::Int8),
1668        "numrange" => DataType::Range(spg_storage::RangeKind::Num),
1669        "tsrange" => DataType::Range(spg_storage::RangeKind::Ts),
1670        "tstzrange" => DataType::Range(spg_storage::RangeKind::TsTz),
1671        "daterange" => DataType::Range(spg_storage::RangeKind::Date),
1672        // Array forms — `::BOOL[]` etc. The parser canonicalises
1673        // postfix `[]` into the `_array` suffix; mirror PG's
1674        // builtin arrays so the cast lands on a typed array Value.
1675        "bool_array" | "boolean_array" => DataType::BoolArray,
1676        "smallint_array" | "int2_array" => DataType::SmallIntArray,
1677        "int_array" | "integer_array" | "int4_array" => DataType::IntArray,
1678        "bigint_array" | "int8_array" => DataType::BigIntArray,
1679        "float_array" | "double_array" | "real_array" => DataType::FloatArray,
1680        "numeric_array" | "decimal_array" => DataType::NumericArray,
1681        "text_array" => DataType::TextArray,
1682        "date_array" => DataType::DateArray,
1683        "timestamp_array" => DataType::TimestampArray,
1684        "timestamptz_array" => DataType::TimestamptzArray,
1685        "uuid_array" => DataType::UuidArray,
1686        "json_array" => DataType::JsonArray,
1687        "jsonb_array" => DataType::JsonbArray,
1688        "bytea_array" => DataType::BytesArray,
1689        "interval_array" => DataType::IntervalArray,
1690        "money_array" => DataType::MoneyArray,
1691        _ => return None,
1692    })
1693}
1694
1695pub(crate) const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
1696    match t {
1697        ColumnTypeName::SmallInt => DataType::SmallInt,
1698        ColumnTypeName::Int => DataType::Int,
1699        ColumnTypeName::BigInt => DataType::BigInt,
1700        ColumnTypeName::Float => DataType::Float,
1701        ColumnTypeName::Text => DataType::Text,
1702        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
1703        ColumnTypeName::Char(n) => DataType::Char(n),
1704        ColumnTypeName::Bool => DataType::Bool,
1705        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
1706            dim,
1707            encoding: match encoding {
1708                SqlVecEncoding::F32 => VecEncoding::F32,
1709                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
1710                SqlVecEncoding::F16 => VecEncoding::F16,
1711            },
1712        },
1713        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
1714        ColumnTypeName::Date => DataType::Date,
1715        ColumnTypeName::Timestamp => DataType::Timestamp,
1716        ColumnTypeName::Timestamptz => DataType::Timestamptz,
1717        ColumnTypeName::Json => DataType::Json,
1718        ColumnTypeName::Jsonb => DataType::Jsonb,
1719        ColumnTypeName::Bytes => DataType::Bytes,
1720        ColumnTypeName::TextArray => DataType::TextArray,
1721        ColumnTypeName::IntArray => DataType::IntArray,
1722        ColumnTypeName::BigIntArray => DataType::BigIntArray,
1723        ColumnTypeName::TsVector => DataType::TsVector,
1724        ColumnTypeName::TsQuery => DataType::TsQuery,
1725        ColumnTypeName::Uuid => DataType::Uuid,
1726        ColumnTypeName::Time => DataType::Time,
1727        ColumnTypeName::Year => DataType::Year,
1728        ColumnTypeName::TimeTz => DataType::TimeTz,
1729        ColumnTypeName::Money => DataType::Money,
1730        ColumnTypeName::Range(k) => DataType::Range(match k {
1731            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
1732            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
1733            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
1734            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
1735            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
1736            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
1737        }),
1738        ColumnTypeName::Hstore => DataType::Hstore,
1739        ColumnTypeName::IntArray2D => DataType::IntArray2D,
1740        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
1741        ColumnTypeName::TextArray2D => DataType::TextArray2D,
1742        ColumnTypeName::Interval => DataType::Interval,
1743        ColumnTypeName::IntervalArray => DataType::IntervalArray,
1744        ColumnTypeName::BoolArray => DataType::BoolArray,
1745        ColumnTypeName::SmallIntArray => DataType::SmallIntArray,
1746        ColumnTypeName::FloatArray => DataType::FloatArray,
1747        ColumnTypeName::NumericArray => DataType::NumericArray,
1748        ColumnTypeName::DateArray => DataType::DateArray,
1749        ColumnTypeName::TimestampArray => DataType::TimestampArray,
1750        ColumnTypeName::TimestamptzArray => DataType::TimestamptzArray,
1751        ColumnTypeName::UuidArray => DataType::UuidArray,
1752        ColumnTypeName::JsonArray => DataType::JsonArray,
1753        ColumnTypeName::JsonbArray => DataType::JsonbArray,
1754        ColumnTypeName::BytesArray => DataType::BytesArray,
1755        ColumnTypeName::VarcharArray => DataType::VarcharArray,
1756        ColumnTypeName::CharArray => DataType::CharArray,
1757        ColumnTypeName::Multirange(k) => DataType::Multirange(match k {
1758            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
1759            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
1760            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
1761            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
1762            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
1763            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
1764        }),
1765        ColumnTypeName::Point => DataType::Point,
1766        ColumnTypeName::Lseg => DataType::Lseg,
1767        ColumnTypeName::Path => DataType::Path,
1768        ColumnTypeName::PgBox => DataType::PgBox,
1769        ColumnTypeName::Polygon => DataType::Polygon,
1770        ColumnTypeName::Line => DataType::Line,
1771        ColumnTypeName::Circle => DataType::Circle,
1772        ColumnTypeName::Inet => DataType::Inet,
1773        ColumnTypeName::Cidr => DataType::Cidr,
1774        ColumnTypeName::Macaddr => DataType::Macaddr,
1775        ColumnTypeName::Macaddr8 => DataType::Macaddr8,
1776        ColumnTypeName::Bit => DataType::Bit,
1777        ColumnTypeName::BitVarying => DataType::BitVarying,
1778        ColumnTypeName::Xml => DataType::Xml,
1779        ColumnTypeName::Char1 => DataType::Char1,
1780        ColumnTypeName::MoneyArray => DataType::MoneyArray,
1781    }
1782}
1783
1784/// Convert an INSERT VALUES expression to a storage Value. Supports literal
1785/// expressions, unary-minus over numeric literals, and pgvector-style
1786/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
1787pub(crate) fn literal_expr_to_value(expr: Expr) -> Result<Value<'static>, EngineError> {
1788    match expr {
1789        Expr::Literal(l) => Ok(literal_to_value(l)),
1790        Expr::Cast { expr, target } => {
1791            let inner_value = literal_expr_to_value(*expr)?;
1792            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
1793        }
1794        Expr::Unary {
1795            op: UnOp::Neg,
1796            expr,
1797        } => match *expr {
1798            Expr::Literal(Literal::Integer(n)) => {
1799                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
1800                // — overflow on negate of i64::MIN is the one edge case.
1801                let neg = n.checked_neg().ok_or_else(|| {
1802                    EngineError::Unsupported("integer literal overflow on negation".into())
1803                })?;
1804                Ok(int_value_for(neg))
1805            }
1806            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
1807            // v7.37.5 ship triage — fold the unary minus through a
1808            // `Cast { Literal, target }` wrapper (`-2::smallint`,
1809            // `-3.14::numeric(10,2)`). We negate the inner literal,
1810            // re-wrap with the same cast, and re-enter the literal
1811            // resolver — the cast path handles the typed result.
1812            Expr::Cast {
1813                expr: inner,
1814                target,
1815            } => {
1816                let negated_inner = match *inner {
1817                    Expr::Literal(Literal::Integer(n)) => {
1818                        let neg = n.checked_neg().ok_or_else(|| {
1819                            EngineError::Unsupported("integer literal overflow on negation".into())
1820                        })?;
1821                        Expr::Literal(Literal::Integer(neg))
1822                    }
1823                    Expr::Literal(Literal::Float(x)) => Expr::Literal(Literal::Float(-x)),
1824                    other => Expr::Unary {
1825                        op: spg_sql::ast::UnOp::Neg,
1826                        expr: alloc::boxed::Box::new(other),
1827                    },
1828                };
1829                literal_expr_to_value(Expr::Cast {
1830                    expr: alloc::boxed::Box::new(negated_inner),
1831                    target,
1832                })
1833            }
1834            other => Err(EngineError::Unsupported(alloc::format!(
1835                "unary minus over non-literal expression: {other:?}"
1836            ))),
1837        },
1838        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
1839        // INSERT-time. Each element must reduce to a Value through
1840        // `literal_expr_to_value`; NULL elements become `None`.
1841        // v7.11.13 — deduce shape from element values: all Int →
1842        // IntArray; any BigInt → BigIntArray (widening); any Text
1843        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
1844        // the outer Cast arm before reaching here and re-coerce.
1845        Expr::Array(items) => {
1846            let mut materialised: alloc::vec::Vec<Value<'static>> =
1847                alloc::vec::Vec::with_capacity(items.len());
1848            for elem in items {
1849                materialised.push(literal_expr_to_value(elem)?);
1850            }
1851            Ok(array_literal_widen(materialised))
1852        }
1853        // Any other Expr shape — fall back to a general evaluation
1854        // against an empty row + empty schema. This unblocks the
1855        // app-common patterns where INSERT VALUES carries a
1856        // non-correlated function call:
1857        //   INSERT INTO t VALUES (concat('U-', 42))
1858        //   INSERT INTO t VALUES (now())
1859        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
1860        // Any expression that references a column or `$N`
1861        // placeholder fails cleanly inside `eval_expr` with a
1862        // descriptive error; literals + casts + ARRAY[…] continue
1863        // to take the fast paths above so the hot INSERT path is
1864        // unchanged on the common case.
1865        other => {
1866            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
1867            let ctx = EvalContext::new(&empty_schema, None);
1868            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
1869            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
1870        }
1871    }
1872}
1873
1874pub(crate) fn literal_to_value(l: Literal) -> Value<'static> {
1875    match l {
1876        Literal::Integer(n) => int_value_for(n),
1877        Literal::Float(x) => Value::Float(x),
1878        Literal::String(s) => Value::text(s),
1879        Literal::Bool(b) => Value::Bool(b),
1880        Literal::Null => Value::Null,
1881        Literal::Vector(v) => Value::vector(v),
1882        Literal::TextArray(items) => Value::TextArray(items),
1883        Literal::IntArray(items) => Value::IntArray(items),
1884        Literal::BigIntArray(items) => Value::BigIntArray(items),
1885        Literal::Interval {
1886            months,
1887            days,
1888            micros,
1889            ..
1890        } => Value::Interval {
1891            months,
1892            days,
1893            micros,
1894        },
1895    }
1896}
1897
1898/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
1899/// columns will still enforce the right tag downstream — this is just the
1900/// default we synthesise from an unannotated integer literal.
1901pub(crate) fn int_value_for(n: i64) -> Value<'static> {
1902    if let Ok(small) = i32::try_from(n) {
1903        Value::Int(small)
1904    } else {
1905        Value::BigInt(n)
1906    }
1907}
1908
1909/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
1910/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
1911/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
1912/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
1913/// `NULL` is always permitted; the nullability check happens later in storage.
1914#[allow(clippy::too_many_lines)]
1915/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
1916/// columns. Called after `coerce_value` at each INSERT / UPDATE
1917/// site that has ColumnSchema context. NULL passes through (a
1918/// nullable UNSIGNED column can legitimately hold NULL).
1919pub(crate) fn check_unsigned_range(
1920    v: &Value,
1921    schema: &ColumnSchema,
1922    position: usize,
1923) -> Result<(), EngineError> {
1924    if !schema.is_unsigned {
1925        return Ok(());
1926    }
1927    let n = match v {
1928        Value::SmallInt(x) => i64::from(*x),
1929        Value::Int(x) => i64::from(*x),
1930        Value::BigInt(x) => *x,
1931        _ => return Ok(()), // non-integer cells (NULL, default) skip
1932    };
1933    if n < 0 {
1934        return Err(EngineError::Unsupported(alloc::format!(
1935            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
1936            schema.name
1937        )));
1938    }
1939    Ok(())
1940}
1941
1942pub(crate) fn coerce_value(
1943    v: Value<'static>,
1944    expected: DataType,
1945    col_name: &str,
1946    position: usize,
1947) -> Result<Value<'static>, EngineError> {
1948    if v.is_null() {
1949        return Ok(Value::Null);
1950    }
1951    let actual = v.data_type().expect("non-null");
1952    if actual == expected {
1953        return Ok(v);
1954    }
1955    let coerced: Option<Value<'static>> = match (v, expected) {
1956        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
1957        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
1958        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
1959        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
1960            i128::from(n),
1961            precision,
1962            scale,
1963            col_name,
1964        )?),
1965        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
1966        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
1967        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
1968        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
1969            i128::from(n),
1970            precision,
1971            scale,
1972            col_name,
1973        )?),
1974        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
1975        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
1976        #[allow(clippy::cast_precision_loss)]
1977        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
1978        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
1979            i128::from(n),
1980            precision,
1981            scale,
1982            col_name,
1983        )?),
1984        (Value::Float(x), DataType::Numeric { precision, scale }) => {
1985            Some(numeric_from_float(x, precision, scale, col_name)?)
1986        }
1987        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
1988        // canonical decimal text (`"-1234.56"` / `"42"` /
1989        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
1990        // to the column's declared scale. Required for prepared
1991        // binds: `value_to_literal` flattens a Value::Numeric
1992        // into a TEXT literal because Literal carries no native
1993        // Numeric variant, so the placeholder substitution path
1994        // reaches coerce_value as Text → Numeric. Without this
1995        // arm the round-trip surfaces a TypeMismatch even though
1996        // the cell already left the engine as a valid Numeric.
1997        (Value::Text(s), DataType::Numeric { precision, scale }) => {
1998            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
1999                return Err(EngineError::Eval(EvalError::TypeMismatch {
2000                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
2001                }));
2002            };
2003            Some(numeric_rescale(
2004                mantissa, src_scale, precision, scale, col_name,
2005            )?)
2006        }
2007        // Text → DATE / TIMESTAMP: parse canonical text forms.
2008        (Value::Text(s), DataType::Date) => {
2009            let d = eval::parse_date_literal(&s).ok_or_else(|| {
2010                EngineError::Eval(EvalError::TypeMismatch {
2011                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
2012                })
2013            })?;
2014            Some(Value::Date(d))
2015        }
2016        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
2017        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
2018        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
2019        // form to the column's numeric / bool type at DEFAULT-
2020        // installation time so the storage check sees a typed
2021        // value. Parse failures fall through to TypeMismatch.
2022        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
2023        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
2024        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
2025        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
2026        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
2027            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
2028            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
2029            _ => None,
2030        },
2031        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
2032        // classifies as DataType::Bool) is the storage shape every
2033        // mysqldump-restored boolean column lands in. mysqldump emits
2034        // the values as integer `0` / `1` literals, so int → bool
2035        // coerce on INSERT is required for a 0-change cutover. MySQL's
2036        // rule is "any non-zero is truthy"; we follow that for all
2037        // signed int widths so the same coerce path serves an
2038        // explicit `BOOLEAN` column too.
2039        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
2040        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
2041        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
2042        // v4.9: Text ↔ JSON coercion. No structural validation —
2043        // any text literal is accepted; the responsibility for
2044        // valid JSON lies with the producer.
2045        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::json(s)),
2046        (Value::Json(s), DataType::Text) => Some(Value::text(s)),
2047        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
2048        // both JSON and JSONB on-disk as `Value::json(String)` —
2049        // they share the underlying text payload. The cast
2050        // `'<text>'::jsonb` produces a Value::Json that needs to
2051        // satisfy a DataType::Jsonb column. Identity coerce in
2052        // both directions so JSON ↔ JSONB assignments work at all
2053        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
2054        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::json(s)),
2055        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
2056        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
2057        //   - Escape: `Hello\\000world`  (backslash + octal triples)
2058        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
2059        // Errors surface as TypeMismatch so the operator gets a
2060        // clear "this literal isn't a bytea literal" hint.
2061        (Value::Text(s), DataType::Bytes) => {
2062            let bytes = decode_bytea_literal(&s).map_err(|e| {
2063                EngineError::Eval(EvalError::TypeMismatch {
2064                    detail: alloc::format!(
2065                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
2066                    ),
2067                })
2068            })?;
2069            Some(Value::bytes(bytes))
2070        }
2071        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
2072        // output (lowercase, `\x` prefix). Important when a
2073        // SELECT pulls a bytea cell through a Text column path.
2074        (Value::Bytes(b), DataType::Text) => Some(Value::text(encode_bytea_hex(&b))),
2075        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
2076        // unhyphenated, uppercase, and `{...}`-braced forms; we
2077        // funnel all four through `spg_storage::parse_uuid_str`.
2078        // A malformed literal surfaces as a SQL TypeMismatch
2079        // rather than silently inserting garbage — `0-change
2080        // cutover` requires that an app inserting bad UUID text
2081        // sees the same hard error PG would raise.
2082        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
2083            Some(b) => Some(Value::Uuid(b)),
2084            None => {
2085                return Err(EngineError::Eval(EvalError::TypeMismatch {
2086                    detail: alloc::format!(
2087                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
2088                    ),
2089                }));
2090            }
2091        },
2092        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
2093        // Surfaces when a SELECT plucks a uuid cell through a
2094        // Text column path (e.g. INSERT INTO log SELECT id::text
2095        // FROM other_table).
2096        (Value::Uuid(b), DataType::Text) => Some(Value::text(spg_storage::format_uuid(&b))),
2097        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
2098        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
2099        // Out-of-range hour/min/sec is a hard SQL error (no
2100        // silent truncation — same 0-change-cutover discipline
2101        // we apply to UUID).
2102        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
2103            Some(us) => Some(Value::Time(us)),
2104            None => {
2105                return Err(EngineError::Eval(EvalError::TypeMismatch {
2106                    detail: alloc::format!(
2107                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
2108                    ),
2109                }));
2110            }
2111        },
2112        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
2113        (Value::Time(us), DataType::Text) => Some(Value::text(eval::format_time(us))),
2114        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
2115        // check enforces the MySQL canonical 1901..=2155 + 0
2116        // sentinel; out-of-range is a hard SQL error (no silent
2117        // truncation, mirrors P0-32 / P0-25 discipline).
2118        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
2119        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
2120        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
2121        // Text → YEAR. Accepts the 4-digit decimal form only;
2122        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
2123        // 5.7 and is out of scope for v7.17.0.
2124        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
2125            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
2126            Err(_) => {
2127                return Err(EngineError::Eval(EvalError::TypeMismatch {
2128                    detail: alloc::format!(
2129                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
2130                    ),
2131                }));
2132            }
2133        },
2134        // YEAR → Text 4-digit zero-padded.
2135        (Value::Year(y), DataType::Text) => Some(Value::text(alloc::format!("{y:04}"))),
2136        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
2137        // signed offset suffix; missing offset is a hard error
2138        // (SPG has no session TZ wired into eval, unlike PG).
2139        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
2140            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
2141            None => {
2142                return Err(EngineError::Eval(EvalError::TypeMismatch {
2143                    detail: alloc::format!(
2144                        "invalid input syntax for type time with time zone: \
2145                         {s:?} (column `{col_name}`)"
2146                    ),
2147                }));
2148            }
2149        },
2150        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
2151        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
2152            Some(Value::text(eval::format_timetz(us, offset_secs)))
2153        }
2154        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
2155        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
2156        // arrive via the Int/BigInt/Float/Numeric arms below.
2157        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
2158            Some(c) => Some(Value::Money(c)),
2159            None => {
2160                return Err(EngineError::Eval(EvalError::TypeMismatch {
2161                    detail: alloc::format!(
2162                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
2163                    ),
2164                }));
2165            }
2166        },
2167        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
2168        // Bare numeric literal is interpreted as a major-unit
2169        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
2170        (Value::SmallInt(n), DataType::Money) => {
2171            Some(Value::Money(i64::from(n).saturating_mul(100)))
2172        }
2173        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
2174        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
2175        (Value::Float(x), DataType::Money) => {
2176            // Round half-away-from-zero to cents (no_std — no
2177            // `f64::round`, so hand-roll via biased truncation).
2178            let scaled = x * 100.0;
2179            let cents = if scaled >= 0.0 {
2180                (scaled + 0.5) as i64
2181            } else {
2182                (scaled - 0.5) as i64
2183            };
2184            Some(Value::Money(cents))
2185        }
2186        (Value::Numeric { scaled, scale }, DataType::Money) => {
2187            // Convert exact decimal to cents (scale 2). If scale > 2,
2188            // round half-away-from-zero. If scale < 2, multiply up.
2189            let cents = if scale == 2 {
2190                scaled
2191            } else if scale < 2 {
2192                let mult = 10_i128.pow(u32::from(2 - scale));
2193                scaled.saturating_mul(mult)
2194            } else {
2195                let div = 10_i128.pow(u32::from(scale - 2));
2196                let half = div / 2;
2197                let bias = if scaled >= 0 { half } else { -half };
2198                (scaled + bias) / div
2199            };
2200            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
2201        }
2202        // MONEY → Text canonical `$N,NNN.CC`.
2203        (Value::Money(c), DataType::Text) => Some(Value::text(eval::format_money(c))),
2204        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
2205        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
2206        // `'(a,b)'`, with empty lower or upper for unbounded.
2207        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
2208            Some(v) => Some(v),
2209            None => {
2210                return Err(EngineError::Eval(EvalError::TypeMismatch {
2211                    detail: alloc::format!(
2212                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
2213                    ),
2214                }));
2215            }
2216        },
2217        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
2218        (v @ Value::Range { .. }, DataType::Text) => Some(Value::text(format_range_str(&v))),
2219        // v7.37.5 ζ-A — Text → network / bit / xml / "char" / money[].
2220        (Value::Text(s), DataType::Inet) => match parse_inet_text(&s) {
2221            Some((family, bits, addr)) => Some(Value::Inet { family, bits, addr }),
2222            None => {
2223                return Err(EngineError::Eval(EvalError::TypeMismatch {
2224                    detail: alloc::format!(
2225                        "invalid input syntax for INET: {s:?} (column `{col_name}`)"
2226                    ),
2227                }));
2228            }
2229        },
2230        (Value::Text(s), DataType::Cidr) => match parse_inet_text(&s) {
2231            Some((family, bits, addr)) => Some(Value::Cidr { family, bits, addr }),
2232            None => {
2233                return Err(EngineError::Eval(EvalError::TypeMismatch {
2234                    detail: alloc::format!(
2235                        "invalid input syntax for CIDR: {s:?} (column `{col_name}`)"
2236                    ),
2237                }));
2238            }
2239        },
2240        (Value::Text(s), DataType::Macaddr) => match parse_macaddr_text(&s) {
2241            Some(m) => Some(Value::Macaddr(m)),
2242            None => {
2243                return Err(EngineError::Eval(EvalError::TypeMismatch {
2244                    detail: alloc::format!(
2245                        "invalid input syntax for MACADDR: {s:?} (column `{col_name}`)"
2246                    ),
2247                }));
2248            }
2249        },
2250        (Value::Text(s), DataType::Macaddr8) => match parse_macaddr8_text(&s) {
2251            Some(m) => Some(Value::Macaddr8(m)),
2252            None => {
2253                return Err(EngineError::Eval(EvalError::TypeMismatch {
2254                    detail: alloc::format!(
2255                        "invalid input syntax for MACADDR8: {s:?} (column `{col_name}`)"
2256                    ),
2257                }));
2258            }
2259        },
2260        // v7.37.5 ship triage — `Value::BitString` self-reports as
2261        // `DataType::BitVarying`(see `Value::data_type`), so an
2262        // INSERT into a `BIT` column triggers a spurious type
2263        // mismatch. Accept BitString into either Bit or BitVarying
2264        // unchanged — the column-side fixed-length contract is a
2265        // schema concern, not a value-shape one.
2266        (Value::BitString { nbits, bytes }, DataType::Bit | DataType::BitVarying) => {
2267            Some(Value::BitString { nbits, bytes })
2268        }
2269        (Value::Text(s), DataType::Bit | DataType::BitVarying) => match parse_bit_string_text(&s) {
2270            Some((nbits, bytes)) => Some(Value::bit_string(nbits, bytes)),
2271            None => {
2272                return Err(EngineError::Eval(EvalError::TypeMismatch {
2273                    detail: alloc::format!(
2274                        "invalid input syntax for BIT: {s:?} (column `{col_name}`)"
2275                    ),
2276                }));
2277            }
2278        },
2279        (Value::Text(s), DataType::Xml) => Some(Value::xml(s)),
2280        (Value::Text(s), DataType::Char1) => {
2281            let b = s.bytes().next().unwrap_or(0);
2282            Some(Value::Char1(b))
2283        }
2284        // v7.37.5 ζ-A — inverse coerces.
2285        (Value::Inet { family, bits, addr }, DataType::Text) => {
2286            Some(Value::text(format_inet(family, bits, &addr)))
2287        }
2288        (Value::Cidr { family, bits, addr }, DataType::Text) => {
2289            Some(Value::text(format_inet(family, bits, &addr)))
2290        }
2291        (Value::Macaddr(m), DataType::Text) => Some(Value::text(format_macaddr(&m))),
2292        (Value::Macaddr8(m), DataType::Text) => Some(Value::text(format_macaddr8(&m))),
2293        (Value::BitString { nbits, bytes }, DataType::Text) => {
2294            Some(Value::text(format_bit_string(nbits, &bytes)))
2295        }
2296        (Value::Xml(s), DataType::Text) => Some(Value::text(s)),
2297        (Value::Char1(b), DataType::Text) => Some(Value::text((b as char).to_string())),
2298        // v7.37.5 ε — Text → geometry coerce. Each parser returns
2299        // None on malformed input; we surface a TypeMismatch with
2300        // the column name so the engine error is debuggable.
2301        (Value::Text(s), DataType::Point) => match parse_point(&s) {
2302            Some(p) => Some(Value::Point(p)),
2303            None => {
2304                return Err(EngineError::Eval(EvalError::TypeMismatch {
2305                    detail: alloc::format!(
2306                        "invalid input syntax for POINT: {s:?} (column `{col_name}`)"
2307                    ),
2308                }));
2309            }
2310        },
2311        (Value::Text(s), DataType::Lseg) => match parse_lseg_text(&s) {
2312            Some((p1, p2)) => Some(Value::Lseg(p1, p2)),
2313            None => {
2314                return Err(EngineError::Eval(EvalError::TypeMismatch {
2315                    detail: alloc::format!(
2316                        "invalid input syntax for LSEG: {s:?} (column `{col_name}`)"
2317                    ),
2318                }));
2319            }
2320        },
2321        (Value::Text(s), DataType::PgBox) => match parse_box_text(&s) {
2322            Some((ur, ll)) => Some(Value::PgBox(ur, ll)),
2323            None => {
2324                return Err(EngineError::Eval(EvalError::TypeMismatch {
2325                    detail: alloc::format!(
2326                        "invalid input syntax for BOX: {s:?} (column `{col_name}`)"
2327                    ),
2328                }));
2329            }
2330        },
2331        (Value::Text(s), DataType::Line) => match parse_line_text(&s) {
2332            Some((a, b, c)) => Some(Value::Line { a, b, c }),
2333            None => {
2334                return Err(EngineError::Eval(EvalError::TypeMismatch {
2335                    detail: alloc::format!(
2336                        "invalid input syntax for LINE: {s:?} (column `{col_name}`)"
2337                    ),
2338                }));
2339            }
2340        },
2341        (Value::Text(s), DataType::Circle) => match parse_circle_text(&s) {
2342            Some((center, radius)) => Some(Value::Circle { center, radius }),
2343            None => {
2344                return Err(EngineError::Eval(EvalError::TypeMismatch {
2345                    detail: alloc::format!(
2346                        "invalid input syntax for CIRCLE: {s:?} (column `{col_name}`)"
2347                    ),
2348                }));
2349            }
2350        },
2351        (Value::Text(s), DataType::Path) => match parse_path_text(&s) {
2352            Some((points, closed)) => Some(Value::Path { points, closed }),
2353            None => {
2354                return Err(EngineError::Eval(EvalError::TypeMismatch {
2355                    detail: alloc::format!(
2356                        "invalid input syntax for PATH: {s:?} (column `{col_name}`)"
2357                    ),
2358                }));
2359            }
2360        },
2361        (Value::Text(s), DataType::Polygon) => match parse_polygon_text(&s) {
2362            Some(points) => Some(Value::Polygon(points)),
2363            None => {
2364                return Err(EngineError::Eval(EvalError::TypeMismatch {
2365                    detail: alloc::format!(
2366                        "invalid input syntax for POLYGON: {s:?} (column `{col_name}`)"
2367                    ),
2368                }));
2369            }
2370        },
2371        // v7.37.5 ε — geometry → Text canonical forms.
2372        (Value::Point(p), DataType::Text) => Some(Value::text(format_point(p))),
2373        (Value::Lseg(p1, p2), DataType::Text) => Some(Value::text(format_lseg(p1, p2))),
2374        (Value::PgBox(ur, ll), DataType::Text) => Some(Value::text(format_pg_box(ur, ll))),
2375        (Value::Line { a, b, c }, DataType::Text) => Some(Value::text(format_line(a, b, c))),
2376        (Value::Circle { center, radius }, DataType::Text) => {
2377            Some(Value::text(format_circle(center, radius)))
2378        }
2379        (Value::Path { points, closed }, DataType::Text) => {
2380            Some(Value::text(format_path(&points, closed)))
2381        }
2382        (Value::Polygon(points), DataType::Text) => Some(Value::text(format_polygon(&points))),
2383        // v7.37.5 δ — Text → Multirange. Accepts `{}` empty and
2384        // `{[a,b),[c,d),...}` comma-separated ranges; each
2385        // subrange parses with the parent kind.
2386        (Value::Text(s), DataType::Multirange(kind)) => match parse_multirange_str(&s, kind) {
2387            Some(ranges) => Some(Value::Multirange { kind, ranges }),
2388            None => {
2389                return Err(EngineError::Eval(EvalError::TypeMismatch {
2390                    detail: alloc::format!(
2391                        "invalid input syntax for multirange type: {s:?} (column `{col_name}`)"
2392                    ),
2393                }));
2394            }
2395        },
2396        // Multirange → Text canonical form (`{[a,b),[c,d)}`).
2397        (Value::Multirange { ranges, .. }, DataType::Text) => {
2398            Some(Value::text(format_multirange(&ranges)))
2399        }
2400        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
2401        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
2402            Some(pairs) => Some(Value::Hstore(pairs)),
2403            None => {
2404                return Err(EngineError::Eval(EvalError::TypeMismatch {
2405                    detail: alloc::format!(
2406                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
2407                    ),
2408                }));
2409            }
2410        },
2411        // Hstore → Text canonical `"k"=>"v"` form.
2412        (Value::Hstore(pairs), DataType::Text) => Some(Value::text(format_hstore_str(&pairs))),
2413        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
2414        // external `'{{a,b},{c,d}}'` literal.
2415        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
2416            Ok(m) => Some(Value::IntArray2D(m)),
2417            Err(e) => {
2418                return Err(EngineError::Eval(EvalError::TypeMismatch {
2419                    detail: alloc::format!(
2420                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
2421                    ),
2422                }));
2423            }
2424        },
2425        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
2426            Ok(m) => Some(Value::BigIntArray2D(m)),
2427            Err(e) => {
2428                return Err(EngineError::Eval(EvalError::TypeMismatch {
2429                    detail: alloc::format!(
2430                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
2431                    ),
2432                }));
2433            }
2434        },
2435        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
2436            Ok(m) => Some(Value::TextArray2D(m)),
2437            Err(e) => {
2438                return Err(EngineError::Eval(EvalError::TypeMismatch {
2439                    detail: alloc::format!(
2440                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
2441                    ),
2442                }));
2443            }
2444        },
2445        // 2D arrays → Text canonical nested form.
2446        (Value::IntArray2D(rows), DataType::Text) => Some(Value::text(format_int_2d_text(&rows))),
2447        (Value::BigIntArray2D(rows), DataType::Text) => {
2448            Some(Value::text(format_bigint_2d_text(&rows)))
2449        }
2450        (Value::TextArray2D(rows), DataType::Text) => Some(Value::text(format_text_2d_text(&rows))),
2451        // v7.10.11 — Text → TEXT[]. Decode PG's external array
2452        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
2453        // is the literal `NULL`; everything else is a quoted or
2454        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
2455        (Value::Text(s), DataType::TextArray) => {
2456            let arr = decode_text_array_literal(&s).map_err(|e| {
2457                EngineError::Eval(EvalError::TypeMismatch {
2458                    detail: alloc::format!(
2459                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
2460                    ),
2461                })
2462            })?;
2463            Some(Value::TextArray(arr))
2464        }
2465        // v7.16.0 — Text → IntArray / BigIntArray for the
2466        // spg-sqlx Bind path. Decode the PG external form
2467        // `{1,2,3}` as a TEXT array first, then parse each
2468        // element as int. Same shape as the TextArray decode
2469        // above with an element-wise narrow.
2470        (Value::Text(s), DataType::IntArray) => {
2471            let arr = decode_text_array_literal(&s).map_err(|e| {
2472                EngineError::Eval(EvalError::TypeMismatch {
2473                    detail: alloc::format!(
2474                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
2475                    ),
2476                })
2477            })?;
2478            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
2479            for elem in arr {
2480                match elem {
2481                    None => out.push(None),
2482                    Some(t) => {
2483                        let n: i32 = t.parse().map_err(|_| {
2484                            EngineError::Eval(EvalError::TypeMismatch {
2485                                detail: alloc::format!(
2486                                    "cannot parse {t:?} as INT element for `{col_name}`"
2487                                ),
2488                            })
2489                        })?;
2490                        out.push(Some(n));
2491                    }
2492                }
2493            }
2494            Some(Value::IntArray(out))
2495        }
2496        (Value::Text(s), DataType::BigIntArray) => {
2497            let arr = decode_text_array_literal(&s).map_err(|e| {
2498                EngineError::Eval(EvalError::TypeMismatch {
2499                    detail: alloc::format!(
2500                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
2501                    ),
2502                })
2503            })?;
2504            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
2505            for elem in arr {
2506                match elem {
2507                    None => out.push(None),
2508                    Some(t) => {
2509                        let n: i64 = t.parse().map_err(|_| {
2510                            EngineError::Eval(EvalError::TypeMismatch {
2511                                detail: alloc::format!(
2512                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
2513                                ),
2514                            })
2515                        })?;
2516                        out.push(Some(n));
2517                    }
2518                }
2519            }
2520            Some(Value::BigIntArray(out))
2521        }
2522        // v7.10.11 — TEXT[] → Text round-trip uses PG's
2523        // external array form (`{a,b,NULL}`). Lets a SELECT
2524        // pull an array column through any Text-side codepath.
2525        (Value::TextArray(items), DataType::Text) => Some(Value::text(encode_text_array(&items))),
2526        // v7.37.5 ship triage — empty `ARRAY[]` literal lands as
2527        // `Value::TextArray(vec![])`. Allow widening to the typed
2528        // array sibling so `ARRAY[]::BOOL[]` / `::FLOAT[]` etc.
2529        // round-trip through INSERT into the typed column. Only
2530        // empty contents go through silently — non-empty TextArray
2531        // must round-trip via per-element parsing(handled by the
2532        // existing element-specific coercion paths above).
2533        (Value::TextArray(items), DataType::BoolArray) if items.is_empty() => {
2534            Some(Value::BoolArray(alloc::vec::Vec::new()))
2535        }
2536        (Value::TextArray(items), DataType::SmallIntArray) if items.is_empty() => {
2537            Some(Value::SmallIntArray(alloc::vec::Vec::new()))
2538        }
2539        (Value::TextArray(items), DataType::IntArray) if items.is_empty() => {
2540            Some(Value::IntArray(alloc::vec::Vec::new()))
2541        }
2542        (Value::TextArray(items), DataType::BigIntArray) if items.is_empty() => {
2543            Some(Value::BigIntArray(alloc::vec::Vec::new()))
2544        }
2545        (Value::TextArray(items), DataType::FloatArray) if items.is_empty() => {
2546            Some(Value::FloatArray(alloc::vec::Vec::new()))
2547        }
2548        (Value::TextArray(items), DataType::NumericArray) if items.is_empty() => {
2549            Some(Value::NumericArray(alloc::vec::Vec::new()))
2550        }
2551        (Value::TextArray(items), DataType::DateArray) if items.is_empty() => {
2552            Some(Value::DateArray(alloc::vec::Vec::new()))
2553        }
2554        (Value::TextArray(items), DataType::TimestampArray) if items.is_empty() => {
2555            Some(Value::TimestampArray(alloc::vec::Vec::new()))
2556        }
2557        (Value::TextArray(items), DataType::TimestamptzArray) if items.is_empty() => {
2558            Some(Value::TimestamptzArray(alloc::vec::Vec::new()))
2559        }
2560        (Value::TextArray(items), DataType::UuidArray) if items.is_empty() => {
2561            Some(Value::UuidArray(alloc::vec::Vec::new()))
2562        }
2563        (Value::TextArray(items), DataType::JsonArray) if items.is_empty() => {
2564            Some(Value::JsonArray(alloc::vec::Vec::new()))
2565        }
2566        (Value::TextArray(items), DataType::JsonbArray) if items.is_empty() => {
2567            Some(Value::JsonbArray(alloc::vec::Vec::new()))
2568        }
2569        (Value::TextArray(items), DataType::BytesArray) if items.is_empty() => {
2570            Some(Value::BytesArray(alloc::vec::Vec::new()))
2571        }
2572        (Value::TextArray(items), DataType::IntervalArray) if items.is_empty() => {
2573            Some(Value::IntervalArray(alloc::vec::Vec::new()))
2574        }
2575        (Value::TextArray(items), DataType::MoneyArray) if items.is_empty() => {
2576            Some(Value::MoneyArray(alloc::vec::Vec::new()))
2577        }
2578        // v7.37.5 ship triage — IntArray(empty) widens to
2579        // SmallIntArray for the `INSERT INTO t (xs) VALUES
2580        // (ARRAY[1::smallint, …])` path where the array literal
2581        // collected mixed int widths into IntArray.
2582        (Value::IntArray(items), DataType::SmallIntArray) => {
2583            let mut out = alloc::vec::Vec::with_capacity(items.len());
2584            let mut ok = true;
2585            for item in items {
2586                match item {
2587                    None => out.push(None),
2588                    Some(n) => match i16::try_from(n) {
2589                        Ok(x) => out.push(Some(x)),
2590                        Err(_) => {
2591                            ok = false;
2592                            break;
2593                        }
2594                    },
2595                }
2596            }
2597            if ok {
2598                Some(Value::SmallIntArray(out))
2599            } else {
2600                None
2601            }
2602        }
2603        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
2604        // Matches the existing Text → TsVector arm and the
2605        // `::vector` cast: PG-canonical pgvector external form
2606        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
2607        // column boundary. Dim mismatch surfaces as TypeMismatch.
2608        // For SQ8 / HALF encodings we chain through the standard
2609        // quantise helpers so the storage shape matches the
2610        // declared encoding without a second coerce pass.
2611        (Value::Text(s), DataType::Vector { dim, encoding }) => {
2612            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
2613                EngineError::Eval(EvalError::TypeMismatch {
2614                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
2615                })
2616            })?;
2617            if parsed.len() != dim as usize {
2618                return Err(EngineError::Eval(EvalError::TypeMismatch {
2619                    detail: alloc::format!(
2620                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
2621                        parsed.len()
2622                    ),
2623                }));
2624            }
2625            Some(match encoding {
2626                VecEncoding::F32 => Value::vector(parsed),
2627                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
2628                VecEncoding::F16 => {
2629                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
2630                }
2631            })
2632        }
2633        // v7.16.1 — Text → TSVECTOR auto-coerce for the
2634        // INSERT-side wire path (mailrs round-9 A.2.a). PG
2635        // implicitly promotes the TEXT literal at INSERT into a
2636        // TSVECTOR column; SPG previously rejected with a hard
2637        // type mismatch, blocking 23,276 pg_dump rows into
2638        // `messages.search_vector`. We route through the same
2639        // `decode_tsvector_external` the `::tsvector` cast
2640        // already uses, so PG-canonical forms (`'word'`,
2641        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
2642        (Value::Text(s), DataType::TsVector) => {
2643            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
2644                EngineError::Eval(EvalError::TypeMismatch {
2645                    detail: alloc::format!(
2646                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
2647                    ),
2648                })
2649            })?;
2650            Some(Value::TsVector(lexs))
2651        }
2652        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
2653            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
2654                EngineError::Eval(EvalError::TypeMismatch {
2655                    detail: alloc::format!(
2656                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
2657                    ),
2658                })
2659            })?;
2660            Some(Value::Timestamp(t))
2661        }
2662        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
2663        // TIMESTAMP → day truncation).
2664        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
2665            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
2666        }
2667        // v7.9.21 — Value::Timestamp lands in either Timestamp
2668        // or Timestamptz columns; the on-disk layout is the
2669        // same i64 microseconds UTC.
2670        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
2671        (Value::Timestamp(t), DataType::Date) => {
2672            let days = t.div_euclid(86_400_000_000);
2673            i32::try_from(days).ok().map(Value::Date)
2674        }
2675        (
2676            Value::Numeric {
2677                scaled,
2678                scale: src_scale,
2679            },
2680            DataType::Numeric { precision, scale },
2681        ) => Some(numeric_rescale(
2682            scaled, src_scale, precision, scale, col_name,
2683        )?),
2684        #[allow(clippy::cast_precision_loss)]
2685        (Value::Numeric { scaled, scale }, DataType::Float) => {
2686            let mut div = 1.0_f64;
2687            for _ in 0..scale {
2688                div *= 10.0;
2689            }
2690            Some(Value::Float((scaled as f64) / div))
2691        }
2692        (Value::Numeric { scaled, scale }, DataType::Int) => {
2693            let truncated = numeric_truncate_to_integer(scaled, scale);
2694            i32::try_from(truncated).ok().map(Value::Int)
2695        }
2696        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
2697            let truncated = numeric_truncate_to_integer(scaled, scale);
2698            i64::try_from(truncated).ok().map(Value::BigInt)
2699        }
2700        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
2701            let truncated = numeric_truncate_to_integer(scaled, scale);
2702            i16::try_from(truncated).ok().map(Value::SmallInt)
2703        }
2704        // VARCHAR(n) enforces an upper bound on character count.
2705        (Value::Text(s), DataType::Varchar(max)) => {
2706            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
2707                Some(Value::text(s))
2708            } else {
2709                return Err(EngineError::Unsupported(alloc::format!(
2710                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
2711                     {} chars",
2712                    s.chars().count()
2713                )));
2714            }
2715        }
2716        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
2717        // when the column declares `VECTOR(N) USING SQ8` and
2718        // the INSERT VALUES expression yields a raw f32 vector
2719        // (the normal pgvector-shape literal). Dim mismatch
2720        // falls through the `_ => None` arm and surfaces as
2721        // `TypeMismatch` with the expected SQ8 column type —
2722        // matching the F32 path's existing error.
2723        (
2724            Value::Vector(v),
2725            DataType::Vector {
2726                dim,
2727                encoding: VecEncoding::Sq8,
2728            },
2729        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
2730        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
2731        // columns. Bit-exact at the storage layer (modulo
2732        // half-precision rounding); no rerank pass needed at
2733        // search time.
2734        (
2735            Value::Vector(v),
2736            DataType::Vector {
2737                dim,
2738                encoding: VecEncoding::F16,
2739            },
2740        ) if v.len() == dim as usize => Some(Value::HalfVector(
2741            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
2742        )),
2743        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
2744        // is already longer we reject (PG truncates trailing-space-only;
2745        // staying strict for v1).
2746        (Value::Text(s), DataType::Char(size)) => {
2747            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
2748            if len > size {
2749                return Err(EngineError::Unsupported(alloc::format!(
2750                    "value for CHAR({size}) column `{col_name}` exceeds length: \
2751                     {len} chars"
2752                )));
2753            }
2754            let need = (size - len) as usize;
2755            let mut padded = s.into_owned();
2756            padded.reserve(need);
2757            for _ in 0..need {
2758                padded.push(' ');
2759            }
2760            Some(Value::text(padded))
2761        }
2762        _ => None,
2763    };
2764    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
2765        column: col_name.into(),
2766        expected,
2767        actual,
2768        position,
2769    }))
2770}