Skip to main content

spg_engine/eval/
cast.rs

1//! `expr::TYPE` CAST evaluation (cut 29 — extracted from `eval.rs`).
2//!
3//! Implements PG-style runtime coercion: the giant `cast_value`
4//! dispatcher plus its per-target helpers (numeric / bool / array /
5//! date / timestamp / interval / vector). Date and timestamp casts
6//! defer to the calendar parsers (`parse_date_literal` /
7//! `parse_timestamp_literal`) that stay in `eval.rs`; tsvector /
8//! tsquery casts defer to the FTS codecs re-exported from
9//! `eval::textsearch`.
10
11use alloc::format;
12use alloc::string::{String, ToString};
13use alloc::vec::Vec;
14
15use spg_sql::ast::CastTarget;
16use spg_storage::Value;
17
18use super::{
19    EvalError, decode_tsquery_external, decode_tsvector_external, parse_date_literal,
20    parse_timestamp_literal, value_to_text,
21};
22
23/// PG-style `expr::TYPE` coercion. NULL always casts as NULL.
24pub fn cast_value(v: Value<'static>, target: CastTarget) -> Result<Value<'static>, EvalError> {
25    if matches!(v, Value::Null) {
26        return Ok(Value::Null);
27    }
28    match target {
29        CastTarget::Vector => cast_to_vector(v),
30        CastTarget::Text => Ok(Value::text(value_to_text(&v))),
31        CastTarget::Int => cast_numeric_to_int(v),
32        CastTarget::BigInt => cast_numeric_to_bigint(v),
33        CastTarget::Float => cast_numeric_to_float(v),
34        CastTarget::Bool => cast_to_bool(v),
35        CastTarget::Date => cast_to_date(v),
36        // TIMESTAMP and TIMESTAMPTZ have identical runtime
37        // representation (i64 microseconds UTC).
38        CastTarget::Timestamp | CastTarget::Timestamptz => cast_to_timestamp(v),
39        // v7.9.25 — `expr::INTERVAL`. Currently only TEXT → Interval
40        // is supported (the mailrs idiom: `$1::INTERVAL` where the
41        // bound param is a string like `'7 days'`).
42        CastTarget::Interval => cast_to_interval(v),
43        // v7.9.25 — `::json` / `::jsonb`. Routes Text → Json
44        // (validation is the producer's responsibility, same as
45        // the column-INSERT path).
46        CastTarget::Json | CastTarget::Jsonb => match v {
47            Value::Json(s) => Ok(Value::json(s)),
48            Value::Text(s) => Ok(Value::json(s)),
49            other => Err(EvalError::TypeMismatch {
50                detail: alloc::format!(
51                    "::json / ::jsonb only accepts TEXT-shape inputs, got {:?}",
52                    other.data_type()
53                ),
54            }),
55        },
56        // v7.17.0 Phase 5.3 — `::regtype` / `::regclass`. PG
57        // semantics: each is a textual catalog-name surfacing as
58        // a numeric OID at the wire layer that renders back as
59        // the original name. SPG has no OID space, but pg_dump /
60        // mailrs / Django code uses the cast purely for textual
61        // round-trip — feeding `'public.t'::regclass::text` into
62        // a downstream `format(…)` or string concat. We map to
63        // that textual contract: Text in → Text out (the schema-
64        // qualifier `public.` is stripped to match PG's default
65        // search_path-aware rendering); numeric in → re-cast to
66        // Text as best-effort; anything else errors.
67        //
68        // Pre-3.3 / pre-5.3 (v7.9.26) the cast surfaced a clean
69        // error; this lifts to accept-and-textify so the dominant
70        // dump-loader pattern unblocks. SPG-shaped queries that
71        // genuinely need an OID for runtime joins are still
72        // documented as unsupported.
73        CastTarget::RegType | CastTarget::RegClass => match v {
74            Value::Text(s) => {
75                // Strip an optional `<schema>.` prefix — PG's
76                // regclass render drops it when the schema is on
77                // the search_path; SPG is single-schema so
78                // dropping is always safe.
79                let bare = s.rsplit('.').next().unwrap_or(&s).to_string();
80                Ok(Value::text(bare))
81            }
82            Value::Int(n) => Ok(Value::text(alloc::format!("{n}"))),
83            Value::BigInt(n) => Ok(Value::text(alloc::format!("{n}"))),
84            other => Err(EvalError::TypeMismatch {
85                detail: alloc::format!(
86                    "::regtype / ::regclass accepts TEXT (name) or integer (oid), got {:?}",
87                    other.data_type()
88                ),
89            }),
90        },
91        // v7.10.11 — `::TEXT[]`. Decode PG external array form
92        // when input is Text; pass through unchanged when it is
93        // already TextArray. Anything else is a type mismatch.
94        CastTarget::TextArray => match v {
95            Value::TextArray(items) => Ok(Value::TextArray(items)),
96            Value::Text(s) => decode_text_array_external(&s).map(Value::TextArray),
97            other => Err(EvalError::TypeMismatch {
98                detail: alloc::format!(
99                    "::TEXT[] only accepts TEXT / TEXT[] inputs, got {:?}",
100                    other.data_type()
101                ),
102            }),
103        },
104        // v7.11.13 — `::INT[]` / `::BIGINT[]`. Decode PG external
105        // form `{1,2,3}` when input is Text; widen TextArray /
106        // IntArray as appropriate.
107        CastTarget::IntArray => cast_to_int_array(v),
108        CastTarget::BigIntArray => cast_to_bigint_array(v),
109        // v7.12.0 — `::tsvector` / `::tsquery`. Decodes PG external
110        // form when input is Text; passes through unchanged when the
111        // input is already the target type. Other inputs are a type
112        // mismatch. Lexer / Porter stemmer arrive in v7.12.1; the
113        // external-form cast at v7.12.0 is the path pg_dump and
114        // direct-literal callers use.
115        CastTarget::TsVector => match v {
116            Value::TsVector(items) => Ok(Value::TsVector(items)),
117            Value::Text(s) => decode_tsvector_external(&s).map(Value::TsVector),
118            other => Err(EvalError::TypeMismatch {
119                detail: alloc::format!(
120                    "::tsvector only accepts TEXT / tsvector inputs, got {:?}",
121                    other.data_type()
122                ),
123            }),
124        },
125        CastTarget::TsQuery => match v {
126            Value::TsQuery(ast) => Ok(Value::TsQuery(ast)),
127            Value::Text(s) => decode_tsquery_external(&s).map(Value::TsQuery),
128            other => Err(EvalError::TypeMismatch {
129                detail: alloc::format!(
130                    "::tsquery only accepts TEXT / tsquery inputs, got {:?}",
131                    other.data_type()
132                ),
133            }),
134        },
135        // v7.17.0 — `::uuid`. Identity for `uuid → uuid`; parse
136        // text via the shared `parse_uuid_str`. Anything else is a
137        // type mismatch — PG also rejects e.g. INT → UUID without
138        // an explicit text bridge.
139        CastTarget::Uuid => match v {
140            Value::Uuid(b) => Ok(Value::Uuid(b)),
141            Value::Text(s) => match spg_storage::parse_uuid_str(&s) {
142                Some(b) => Ok(Value::Uuid(b)),
143                None => Err(EvalError::TypeMismatch {
144                    detail: alloc::format!("invalid input syntax for type uuid: {s:?}"),
145                }),
146            },
147            other => Err(EvalError::TypeMismatch {
148                detail: alloc::format!(
149                    "::uuid only accepts TEXT / uuid inputs, got {:?}",
150                    other.data_type()
151                ),
152            }),
153        },
154        // v7.18 — `::bytea`. Identity for `Bytes → Bytes`; decode
155        // Text via the engine's PG-format bytea decoder (`\x`
156        // hex form + `\NNN` escape form). Anything else is a type
157        // mismatch — same shape as PG's contract. Closes the
158        // mailrs D-pre #3 reverse-acceptance gap.
159        CastTarget::Bytea => match v {
160            Value::Bytes(b) => Ok(Value::bytes(b)),
161            Value::Text(s) => match crate::conversions::decode_bytea_literal(&s) {
162                Ok(b) => Ok(Value::bytes(b)),
163                Err(msg) => Err(EvalError::TypeMismatch {
164                    detail: alloc::format!("invalid input syntax for type bytea: {msg}"),
165                }),
166            },
167            other => Err(EvalError::TypeMismatch {
168                detail: alloc::format!(
169                    "::bytea only accepts TEXT / bytea inputs, got {:?}",
170                    other.data_type()
171                ),
172            }),
173        },
174        CastTarget::Named(name) => {
175            // v7.37.5 ship triage — generic typed-cast dispatch.
176            // Resolve the ident to a `DataType` and route the value
177            // through the existing `coerce_value` text-decoder for
178            // every v7.37.5 γ/δ/ε/ζ-A type that already speaks
179            // Text→typed via codec.
180            let dt = crate::conversions::type_name_to_data_type(&name).ok_or_else(|| {
181                EvalError::TypeMismatch {
182                    detail: alloc::format!("unsupported cast target `::{name}`"),
183                }
184            })?;
185            crate::conversions::coerce_value(v, dt, &name, 0).map_err(|e| EvalError::TypeMismatch {
186                detail: alloc::format!("{e}"),
187            })
188        }
189    }
190}
191
192fn cast_to_int_array(v: Value) -> Result<Value, EvalError> {
193    match v {
194        Value::IntArray(items) => Ok(Value::IntArray(items)),
195        Value::BigIntArray(items) => {
196            let mut out: Vec<Option<i32>> = Vec::with_capacity(items.len());
197            for item in items {
198                match item {
199                    None => out.push(None),
200                    Some(n) => match i32::try_from(n) {
201                        Ok(x) => out.push(Some(x)),
202                        Err(_) => {
203                            return Err(EvalError::TypeMismatch {
204                                detail: alloc::format!("::INT[] element {n} overflows i32"),
205                            });
206                        }
207                    },
208                }
209            }
210            Ok(Value::IntArray(out))
211        }
212        Value::Text(s) => decode_int_array_external(&s).map(Value::IntArray),
213        Value::TextArray(items) => {
214            let mut out: Vec<Option<i32>> = Vec::with_capacity(items.len());
215            for item in items {
216                match item {
217                    None => out.push(None),
218                    Some(s) => match s.parse::<i32>() {
219                        Ok(n) => out.push(Some(n)),
220                        Err(_) => {
221                            return Err(EvalError::TypeMismatch {
222                                detail: alloc::format!("::INT[] cannot parse {s:?}"),
223                            });
224                        }
225                    },
226                }
227            }
228            Ok(Value::IntArray(out))
229        }
230        other => Err(EvalError::TypeMismatch {
231            detail: alloc::format!("::INT[] does not accept {:?}", other.data_type()),
232        }),
233    }
234}
235
236fn cast_to_bigint_array(v: Value) -> Result<Value, EvalError> {
237    match v {
238        Value::BigIntArray(items) => Ok(Value::BigIntArray(items)),
239        Value::IntArray(items) => Ok(Value::BigIntArray(
240            items.into_iter().map(|x| x.map(i64::from)).collect(),
241        )),
242        Value::Text(s) => decode_bigint_array_external(&s).map(Value::BigIntArray),
243        Value::TextArray(items) => {
244            let mut out: Vec<Option<i64>> = Vec::with_capacity(items.len());
245            for item in items {
246                match item {
247                    None => out.push(None),
248                    Some(s) => match s.parse::<i64>() {
249                        Ok(n) => out.push(Some(n)),
250                        Err(_) => {
251                            return Err(EvalError::TypeMismatch {
252                                detail: alloc::format!("::BIGINT[] cannot parse {s:?}"),
253                            });
254                        }
255                    },
256                }
257            }
258            Ok(Value::BigIntArray(out))
259        }
260        other => Err(EvalError::TypeMismatch {
261            detail: alloc::format!("::BIGINT[] does not accept {:?}", other.data_type()),
262        }),
263    }
264}
265
266fn decode_int_array_external(s: &str) -> Result<Vec<Option<i32>>, EvalError> {
267    let trimmed = s.trim();
268    let inner = trimmed
269        .strip_prefix('{')
270        .and_then(|x| x.strip_suffix('}'))
271        .ok_or_else(|| EvalError::TypeMismatch {
272            detail: alloc::format!("INT[] literal {s:?} must be enclosed in '{{...}}'"),
273        })?;
274    if inner.trim().is_empty() {
275        return Ok(Vec::new());
276    }
277    inner
278        .split(',')
279        .map(|part| {
280            let p = part.trim();
281            if p.eq_ignore_ascii_case("NULL") {
282                Ok(None)
283            } else {
284                p.parse::<i32>()
285                    .map(Some)
286                    .map_err(|_| EvalError::TypeMismatch {
287                        detail: alloc::format!("INT[] element {p:?} is not an i32"),
288                    })
289            }
290        })
291        .collect()
292}
293
294fn decode_bigint_array_external(s: &str) -> Result<Vec<Option<i64>>, EvalError> {
295    let trimmed = s.trim();
296    let inner = trimmed
297        .strip_prefix('{')
298        .and_then(|x| x.strip_suffix('}'))
299        .ok_or_else(|| EvalError::TypeMismatch {
300            detail: alloc::format!("BIGINT[] literal {s:?} must be enclosed in '{{...}}'"),
301        })?;
302    if inner.trim().is_empty() {
303        return Ok(Vec::new());
304    }
305    inner
306        .split(',')
307        .map(|part| {
308            let p = part.trim();
309            if p.eq_ignore_ascii_case("NULL") {
310                Ok(None)
311            } else {
312                p.parse::<i64>()
313                    .map(Some)
314                    .map_err(|_| EvalError::TypeMismatch {
315                        detail: alloc::format!("BIGINT[] element {p:?} is not an i64"),
316                    })
317            }
318        })
319        .collect()
320}
321
322/// v7.10.11 — same decoder as `decode_text_array_literal` in
323/// `lib.rs`, but lives here so the eval-time cast path stays
324/// inside `spg-engine::eval`. Kept in lock-step with the engine
325/// `coerce_value` decoder by tests.
326fn decode_text_array_external(s: &str) -> Result<Vec<Option<String>>, EvalError> {
327    let trimmed = s.trim();
328    let inner = trimmed
329        .strip_prefix('{')
330        .and_then(|x| x.strip_suffix('}'))
331        .ok_or_else(|| EvalError::TypeMismatch {
332            detail: alloc::format!("TEXT[] literal {s:?} must be enclosed in '{{...}}'"),
333        })?;
334    let mut out: Vec<Option<String>> = Vec::new();
335    if inner.trim().is_empty() {
336        return Ok(out);
337    }
338    let bytes = inner.as_bytes();
339    let mut i = 0;
340    while i <= bytes.len() {
341        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
342            i += 1;
343        }
344        if i < bytes.len() && bytes[i] == b'"' {
345            i += 1;
346            let mut buf = String::new();
347            while i < bytes.len() && bytes[i] != b'"' {
348                if bytes[i] == b'\\' && i + 1 < bytes.len() {
349                    buf.push(bytes[i + 1] as char);
350                    i += 2;
351                } else {
352                    buf.push(bytes[i] as char);
353                    i += 1;
354                }
355            }
356            if i >= bytes.len() {
357                return Err(EvalError::TypeMismatch {
358                    detail: "unterminated quoted element in TEXT[] literal".into(),
359                });
360            }
361            i += 1;
362            out.push(Some(buf));
363        } else {
364            let start = i;
365            while i < bytes.len() && bytes[i] != b',' {
366                i += 1;
367            }
368            let raw = inner[start..i].trim();
369            if raw.eq_ignore_ascii_case("NULL") {
370                out.push(None);
371            } else {
372                out.push(Some(raw.to_string()));
373            }
374        }
375        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
376            i += 1;
377        }
378        if i >= bytes.len() {
379            break;
380        }
381        if bytes[i] != b',' {
382            return Err(EvalError::TypeMismatch {
383                detail: "expected ',' between TEXT[] elements".into(),
384            });
385        }
386        i += 1;
387    }
388    Ok(out)
389}
390
391fn cast_to_interval(v: Value) -> Result<Value, EvalError> {
392    match v {
393        Value::Interval {
394            months,
395            days,
396            micros,
397        } => Ok(Value::Interval {
398            months,
399            days,
400            micros,
401        }),
402        Value::Text(s) => {
403            let (months, days, micros) =
404                spg_sql::parser::parse_interval_text(&s).ok_or_else(|| {
405                    EvalError::TypeMismatch {
406                        detail: alloc::format!("cannot parse {s:?} as INTERVAL"),
407                    }
408                })?;
409            Ok(Value::Interval {
410                months,
411                days,
412                micros,
413            })
414        }
415        other => Err(EvalError::TypeMismatch {
416            detail: alloc::format!(
417                "::INTERVAL only accepts TEXT-shape inputs, got {:?}",
418                other.data_type()
419            ),
420        }),
421    }
422}
423
424fn cast_to_date(v: Value) -> Result<Value, EvalError> {
425    match v {
426        Value::Date(d) => Ok(Value::Date(d)),
427        // Integer literals carry days since the Unix epoch — used by
428        // the `CURRENT_DATE` AST rewrite to inject the wall clock.
429        Value::Int(n) => Ok(Value::Date(n)),
430        Value::BigInt(n) => {
431            i32::try_from(n)
432                .map(Value::Date)
433                .map_err(|_| EvalError::TypeMismatch {
434                    detail: "bigint days-since-epoch out of DATE range".into(),
435                })
436        }
437        // Timestamp truncates to its day boundary.
438        Value::Timestamp(t) => {
439            let days = t.div_euclid(86_400_000_000);
440            i32::try_from(days)
441                .map(Value::Date)
442                .map_err(|_| EvalError::TypeMismatch {
443                    detail: "timestamp out of DATE range".into(),
444                })
445        }
446        Value::Text(s) => parse_date_literal(&s)
447            .map(Value::Date)
448            .ok_or(EvalError::TypeMismatch {
449                detail: format!("cannot parse {s:?} as DATE (expected YYYY-MM-DD)"),
450            }),
451        other => Err(EvalError::TypeMismatch {
452            detail: format!("cannot cast {:?} to DATE", other.data_type()),
453        }),
454    }
455}
456
457fn cast_to_timestamp(v: Value) -> Result<Value, EvalError> {
458    match v {
459        Value::Timestamp(t) => Ok(Value::Timestamp(t)),
460        // Int / BigInt carry microseconds since the Unix epoch — used
461        // by the `NOW()` / `CURRENT_TIMESTAMP` AST rewrite to inject
462        // the wall clock as a plain integer literal.
463        Value::Int(n) => Ok(Value::Timestamp(i64::from(n))),
464        Value::BigInt(n) => Ok(Value::Timestamp(n)),
465        // DATE → TIMESTAMP picks midnight on the date.
466        Value::Date(d) => Ok(Value::Timestamp(i64::from(d) * 86_400_000_000)),
467        Value::Text(s) => {
468            parse_timestamp_literal(&s)
469                .map(Value::Timestamp)
470                .ok_or(EvalError::TypeMismatch {
471                    detail: format!(
472                        "cannot parse {s:?} as TIMESTAMP \
473                     (expected YYYY-MM-DD[ HH:MM:SS[.ffffff]])"
474                    ),
475                })
476        }
477        other => Err(EvalError::TypeMismatch {
478            detail: format!("cannot cast {:?} to TIMESTAMP", other.data_type()),
479        }),
480    }
481}
482
483fn cast_numeric_to_int(v: Value) -> Result<Value, EvalError> {
484    match v {
485        Value::Int(n) => Ok(Value::Int(n)),
486        Value::BigInt(n) => i32::try_from(n)
487            .map(Value::Int)
488            .map_err(|_| EvalError::TypeMismatch {
489                detail: format!("bigint {n} does not fit in int"),
490            }),
491        #[allow(clippy::cast_possible_truncation)]
492        Value::Float(x) => Ok(Value::Int(x as i32)),
493        Value::Text(s) => {
494            s.trim()
495                .parse::<i32>()
496                .map(Value::Int)
497                .map_err(|_| EvalError::TypeMismatch {
498                    detail: format!("cannot parse {s:?} as int"),
499                })
500        }
501        Value::Bool(b) => Ok(Value::Int(i32::from(b))),
502        other => Err(EvalError::TypeMismatch {
503            detail: format!("cannot cast {:?} to int", other.data_type()),
504        }),
505    }
506}
507
508fn cast_numeric_to_bigint(v: Value) -> Result<Value, EvalError> {
509    match v {
510        Value::Int(n) => Ok(Value::BigInt(i64::from(n))),
511        Value::BigInt(n) => Ok(Value::BigInt(n)),
512        #[allow(clippy::cast_possible_truncation)]
513        Value::Float(x) => Ok(Value::BigInt(x as i64)),
514        Value::Text(s) => {
515            s.trim()
516                .parse::<i64>()
517                .map(Value::BigInt)
518                .map_err(|_| EvalError::TypeMismatch {
519                    detail: format!("cannot parse {s:?} as bigint"),
520                })
521        }
522        Value::Bool(b) => Ok(Value::BigInt(i64::from(b))),
523        other => Err(EvalError::TypeMismatch {
524            detail: format!("cannot cast {:?} to bigint", other.data_type()),
525        }),
526    }
527}
528
529fn cast_numeric_to_float(v: Value) -> Result<Value, EvalError> {
530    match v {
531        Value::Int(n) => Ok(Value::Float(f64::from(n))),
532        #[allow(clippy::cast_precision_loss)]
533        Value::BigInt(n) => Ok(Value::Float(n as f64)),
534        Value::Float(x) => Ok(Value::Float(x)),
535        Value::Text(s) => {
536            s.trim()
537                .parse::<f64>()
538                .map(Value::Float)
539                .map_err(|_| EvalError::TypeMismatch {
540                    detail: format!("cannot parse {s:?} as float"),
541                })
542        }
543        other => Err(EvalError::TypeMismatch {
544            detail: format!("cannot cast {:?} to float", other.data_type()),
545        }),
546    }
547}
548
549fn cast_to_bool(v: Value) -> Result<Value, EvalError> {
550    match v {
551        Value::Bool(b) => Ok(Value::Bool(b)),
552        Value::Int(n) => Ok(Value::Bool(n != 0)),
553        Value::BigInt(n) => Ok(Value::Bool(n != 0)),
554        Value::Text(s) => {
555            let lo = s.trim().to_ascii_lowercase();
556            match lo.as_str() {
557                "true" | "t" | "yes" | "y" | "1" | "on" => Ok(Value::Bool(true)),
558                "false" | "f" | "no" | "n" | "0" | "off" => Ok(Value::Bool(false)),
559                _ => Err(EvalError::TypeMismatch {
560                    detail: format!("cannot parse {s:?} as bool"),
561                }),
562            }
563        }
564        other => Err(EvalError::TypeMismatch {
565            detail: format!("cannot cast {:?} to bool", other.data_type()),
566        }),
567    }
568}
569
570/// Parse a `Value::text("[1.0, 2.0, 3.0]")` into a `Value::vector(..)`. Mirrors
571/// pgvector's `'[..]'::vector` cast. NULL casts as NULL.
572pub fn cast_to_vector(v: Value) -> Result<Value<'static>, EvalError> {
573    match v {
574        Value::Null => Ok(Value::Null),
575        Value::Vector(v) => Ok(Value::vector(v.into_owned())),
576        Value::Text(s) => parse_vector_text(&s)
577            .map(Value::vector)
578            .ok_or(EvalError::TypeMismatch {
579                detail: format!("cannot parse {s:?} as a vector literal"),
580            }),
581        other => Err(EvalError::TypeMismatch {
582            detail: format!("::vector requires text input, got {:?}", other.data_type()),
583        }),
584    }
585}
586
587/// Parse `"[1.0, 2.0, -3]"` into `Vec<f32>`. Returns `None` on malformed input.
588pub fn parse_vector_text(s: &str) -> Option<Vec<f32>> {
589    let trimmed = s.trim();
590    let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
591    let trimmed_inner = inner.trim();
592    if trimmed_inner.is_empty() {
593        return Some(Vec::new());
594    }
595    let mut out = Vec::new();
596    for part in trimmed_inner.split(',') {
597        let f: f32 = part.trim().parse().ok()?;
598        out.push(f);
599    }
600    Some(out)
601}