Skip to main content

uqa_sql/expr/casting/
mod.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! SQL cast dispatch and scalar, numeric, and range conversion.
8
9mod array;
10mod binary_oid;
11mod legacy_vector;
12mod temporal;
13
14use super::{
15    multirange_from_ranges, out_of_range, parse_json, parse_multirange, parse_range, to_decimal,
16    to_f64, typed_json_value, value_to_json, value_to_string, vector_value_to_string, ArrayValue,
17    Result, SQLError, TemporalValue, Value,
18};
19use crate::ast::RangeSubtype;
20
21/// Cast a value to the named SQL type, mirroring `CAST(expr AS ty)`.
22/// Types outside the engine's coercion surface return
23/// [`SQLError::Unsupported`].
24pub fn cast_value(v: &Value, ty: &str) -> Result<Value> {
25    cast_value_from(v, ty, None)
26}
27
28/// Cast a value while preserving an explicitly declared source type when the runtime carrier erases it. `PostgreSQL` 18 integer-to-`bytea`/`oid` casts and `xid` cast rejection require the source's declared identity.
29#[expect(
30    clippy::too_many_lines,
31    reason = "cast matrix preserves source-target and error precedence"
32)]
33pub fn cast_value_from(v: &Value, ty: &str, source_ty: Option<&str>) -> Result<Value> {
34    let normalized_type = ty.trim().to_ascii_lowercase();
35    if normalized_type
36        .strip_suffix("[]")
37        .is_some_and(|element| element.trim() == "void" || element.trim() == "pg_catalog.void")
38    {
39        return Err(SQLError::Routine {
40            sqlstate: "42704".into(),
41            message: "type \"void[]\" does not exist".into(),
42        });
43    }
44    if matches!(v, Value::Null) {
45        return Ok(Value::Null);
46    }
47    let (base, modifier) = split_type_modifier(ty);
48    let target = base
49        .trim()
50        .strip_prefix("pg_catalog.")
51        .unwrap_or(base.trim());
52    if matches!(v, Value::Void)
53        && !matches!(
54            target,
55            "void"
56                | "text"
57                | "name"
58                | "varchar"
59                | "character varying"
60                | "bpchar"
61                | "character"
62                | "char"
63        )
64    {
65        return Err(undefined_cast("void", postgres_type_display_name(target)));
66    }
67    if let Some(elem_ty) = ty.strip_suffix("[]") {
68        let source_elem_ty = source_ty
69            .and_then(|source| source.trim().strip_suffix("[]"))
70            .map(str::trim);
71        let array = match v {
72            Value::Array(array) => array.clone(),
73            Value::Str(s) => parse_pg_array_literal(s)?,
74            other => {
75                return Err(SQLError::TypeMismatch(format!(
76                    "CAST AS {ty}: expected array, got {other:?}"
77                )));
78            }
79        };
80        let elements = cast_array_elements(array.elements(), elem_ty, source_elem_ty)?;
81        return ArrayValue::with_lower_bounds(elements, array.lower_bounds().to_vec())
82            .map(Value::Array)
83            .ok_or_else(|| SQLError::TypeMismatch("array dimensions changed during cast".into()));
84    }
85    match base {
86        "void" | "pg_catalog.void" => {
87            let source = canonical_cast_source(source_ty, v);
88            if matches!(
89                source.as_str(),
90                "unknown" | "text" | "name" | "varchar" | "bpchar" | "void"
91            ) {
92                Ok(Value::Void)
93            } else {
94                Err(undefined_cast(postgres_type_display_name(&source), "void"))
95            }
96        }
97        "smallint" | "int2" | "pg_catalog.int2" => cast_integer(v, "smallint"),
98        "integer" | "int" | "int4" | "serial" | "serial4" | "pg_catalog.int4" => {
99            cast_integer(v, "integer")
100        }
101        "bigint" | "int8" | "bigserial" | "serial8" | "pg_catalog.int8" => {
102            cast_integer(v, "bigint")
103        }
104        "real" | "float4" | "float8" | "double" | "double precision" => {
105            Ok(Value::Float(to_f64(v)?))
106        }
107        "numeric" | "decimal" => {
108            let value = to_decimal(v)?;
109            if let Some(modifier) = modifier {
110                let mut parts = modifier.split(',').map(str::trim);
111                let precision: u32 = parts
112                    .next()
113                    .and_then(|p| p.parse().ok())
114                    .ok_or_else(|| SQLError::TypeMismatch("bad numeric precision".into()))?;
115                let scale: i32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
116                let rounded = value
117                    .round_to_scale(scale)
118                    .ok_or_else(|| out_of_range("numeric"))?;
119                if !rounded.fits_precision(precision, scale) {
120                    return Err(SQLError::Routine {
121                        sqlstate: "22003".into(),
122                        message: format!(
123                            "numeric field overflow: A field with precision {precision}, scale {scale} cannot hold value {}",
124                            value.to_sql_string()
125                        ),
126                    });
127                }
128                return Ok(Value::Decimal(rounded));
129            }
130            Ok(Value::Decimal(value))
131        }
132        "regproc" | "regprocedure" | "regrole" | "regtype" if matches!(v, Value::Int(_)) => {
133            Ok(v.clone())
134        }
135        "text"
136        | "refcursor"
137        | "pg_catalog.refcursor"
138        | "name"
139        | "regproc"
140        | "regprocedure"
141        | "regtype"
142        | "pg_node_tree"
143        | "aclitem" => {
144            let source = source_ty
145                .map(str::trim)
146                .map(|source| source.strip_prefix("pg_catalog.").unwrap_or(source));
147            let text = match (source, v) {
148                (Some("int2vector" | "oidvector"), _) => {
149                    vector_value_to_string(v).unwrap_or_else(|| value_to_string(v))
150                }
151                (
152                    Some(
153                        "regproc" | "regprocedure" | "regclass" | "regnamespace" | "regrole"
154                        | "regtype",
155                    ),
156                    Value::Int(0),
157                ) => "-".into(),
158                _ => value_to_string(v),
159            };
160            Ok(Value::Str(text))
161        }
162        "int2vector" | "pg_catalog.int2vector" => legacy_vector::cast_int2vector(v, source_ty),
163        "oidvector" | "pg_catalog.oidvector" => legacy_vector::cast_oidvector(v, source_ty),
164        "oid" | "pg_catalog.oid" => cast_oid(v, source_ty),
165        "regclass" | "pg_catalog.regclass" => cast_regclass(v, source_ty),
166        "regnamespace" | "pg_catalog.regnamespace" => cast_regnamespace(v, source_ty),
167        "regrole" | "pg_catalog.regrole" => cast_regrole(v, source_ty),
168        "xid" | "pg_catalog.xid" => cast_xid(v, source_ty),
169        "\"char\"" => {
170            let text = value_to_string(v);
171            let mut characters = text.chars();
172            let Some(character) = characters.next() else {
173                return Ok(Value::Str(String::new()));
174            };
175            if characters.next().is_some() || !character.is_ascii() {
176                return Err(SQLError::TypeMismatch(format!(
177                    "value too long for type character(1): {text:?}"
178                )));
179            }
180            Ok(Value::Str(character.to_string()))
181        }
182        "uuid" => cast_uuid(v),
183        // varchar(n): an explicit cast truncates to the declared length.
184        "varchar" | "character varying" => {
185            let text = value_to_string(v);
186            let Some(modifier) = modifier else {
187                return Ok(Value::Str(text));
188            };
189            let limit: usize = modifier
190                .trim()
191                .parse()
192                .map_err(|_| SQLError::TypeMismatch(format!("bad length modifier {modifier}")))?;
193            Ok(Value::Str(text.chars().take(limit).collect()))
194        }
195        // bpchar is physically blank-padded. Its implicit text coercion strips
196        // those spaces, while a direct result retains them.
197        "bpchar" if modifier.is_none() => Ok(Value::FixedChar(value_to_string(v))),
198        "character" | "char" | "bpchar" => {
199            let text = value_to_string(v);
200            let limit: usize = match modifier {
201                Some(modifier) => modifier.trim().parse().map_err(|_| {
202                    SQLError::TypeMismatch(format!("bad length modifier {modifier}"))
203                })?,
204                None => 1,
205            };
206            if limit == 0 {
207                return Err(SQLError::TypeMismatch(
208                    "CHARACTER length must be greater than zero".into(),
209                ));
210            }
211            let mut text = text.chars().take(limit).collect::<String>();
212            text.extend(std::iter::repeat_n(
213                ' ',
214                limit.saturating_sub(text.chars().count()),
215            ));
216            Ok(Value::FixedChar(text))
217        }
218        "date" => cast_date(v, source_ty),
219        "time" | "time without time zone" => cast_temporal(
220            v,
221            TemporalCastTarget::Time,
222            TemporalValue::parse_time,
223            "time",
224        ),
225        "timetz" | "time with time zone" => cast_temporal(
226            v,
227            TemporalCastTarget::TimeTz,
228            TemporalValue::parse_time_tz,
229            "time with time zone",
230        ),
231        "timestamp" | "datetime" | "timestamp without time zone" => cast_temporal(
232            v,
233            TemporalCastTarget::Timestamp,
234            TemporalValue::parse_timestamp,
235            "timestamp",
236        ),
237        "timestamptz" | "timestamp with time zone" => cast_temporal(
238            v,
239            TemporalCastTarget::TimestampTz,
240            TemporalValue::parse_timestamp_tz,
241            "timestamp with time zone",
242        ),
243        "interval" => cast_temporal(
244            v,
245            TemporalCastTarget::Interval,
246            TemporalValue::parse_interval,
247            "interval",
248        ),
249        "int4range" => cast_range(v, source_ty, RangeSubtype::Integer),
250        "int8range" => cast_range(v, source_ty, RangeSubtype::BigInteger),
251        "numrange" => cast_range(v, source_ty, RangeSubtype::Numeric),
252        "daterange" => cast_range(v, source_ty, RangeSubtype::Date),
253        "tsrange" => cast_range(v, source_ty, RangeSubtype::Timestamp),
254        "tstzrange" => cast_range(v, source_ty, RangeSubtype::TimestampTz),
255        "int4multirange" => cast_multirange(v, source_ty, RangeSubtype::Integer),
256        "int8multirange" => cast_multirange(v, source_ty, RangeSubtype::BigInteger),
257        "nummultirange" => cast_multirange(v, source_ty, RangeSubtype::Numeric),
258        "datemultirange" => cast_multirange(v, source_ty, RangeSubtype::Date),
259        "tsmultirange" => cast_multirange(v, source_ty, RangeSubtype::Timestamp),
260        "tstzmultirange" => cast_multirange(v, source_ty, RangeSubtype::TimestampTz),
261        "json" => {
262            if let Value::Json(text) = v {
263                return Ok(Value::Json(text.clone()));
264            }
265            if let Value::Str(text) | Value::FixedChar(text) = v {
266                let _validated = parse_json(text)?;
267                return Ok(Value::Json(text.clone()));
268            }
269            typed_json_value(&value_to_json(v), false)
270        }
271        "jsonb" => {
272            if let Value::JsonB(text) = v {
273                return Ok(Value::JsonB(text.clone()));
274            }
275            let parsed = match v {
276                Value::Json(text) | Value::Str(text) | Value::FixedChar(text) => parse_json(text)?,
277                other => value_to_json(other),
278            };
279            typed_json_value(&parsed, true)
280        }
281        "bytea" => cast_bytea(v, source_ty),
282        "boolean" | "bool" => cast_boolean(v),
283        other => Err(SQLError::Unsupported(format!("CAST AS {other}"))),
284    }
285}
286
287fn cast_range(v: &Value, source_ty: Option<&str>, subtype: RangeSubtype) -> Result<Value> {
288    let source = source_ty.map(canonical_type_name);
289    if source.as_deref().is_some_and(|source| {
290        source != subtype.range_name() && !matches!(source, "unknown" | "cstring")
291    }) {
292        return Err(undefined_cast(
293            source.as_deref().unwrap_or("unknown"),
294            subtype.range_name(),
295        ));
296    }
297    let (Value::Str(text) | Value::FixedChar(text)) = v else {
298        return Err(undefined_cast(
299            source.as_deref().unwrap_or("unknown"),
300            subtype.range_name(),
301        ));
302    };
303    parse_range(text, subtype).map(|range| Value::Str(range.to_text()))
304}
305
306fn cast_multirange(v: &Value, source_ty: Option<&str>, subtype: RangeSubtype) -> Result<Value> {
307    let source = source_ty.map(canonical_type_name);
308    let (Value::Str(text) | Value::FixedChar(text)) = v else {
309        return Err(undefined_cast(
310            source.as_deref().unwrap_or("unknown"),
311            subtype.multirange_name(),
312        ));
313    };
314    match source.as_deref() {
315        Some(source) if source == subtype.range_name() => {
316            let range = parse_range(text, subtype)?;
317            Ok(Value::Str(
318                multirange_from_ranges(subtype, [range]).to_text(),
319            ))
320        }
321        None | Some("unknown" | "cstring") => {
322            parse_multirange(text, subtype).map(|multirange| Value::Str(multirange.to_text()))
323        }
324        Some(source) if source == subtype.multirange_name() => {
325            parse_multirange(text, subtype).map(|multirange| Value::Str(multirange.to_text()))
326        }
327        Some(source) => Err(undefined_cast(source, subtype.multirange_name())),
328    }
329}
330
331fn canonical_type_name(type_name: &str) -> String {
332    let normalized = type_name.trim().to_ascii_lowercase();
333    normalized
334        .strip_prefix("pg_catalog.")
335        .unwrap_or(&normalized)
336        .to_string()
337}
338
339/// Apply `PostgreSQL` prefix `-` while retaining the operand's declared type.
340pub fn negate_value(value: &Value, source_ty: Option<&str>) -> Result<Value> {
341    if matches!(value, Value::Null) {
342        return Ok(Value::Null);
343    }
344    let source = canonical_cast_source(source_ty, value);
345    match (source.as_str(), value) {
346        ("int2", Value::Int(value)) => i16::try_from(*value)
347            .ok()
348            .and_then(i16::checked_neg)
349            .map(|value| Value::Int(i64::from(value)))
350            .ok_or_else(|| out_of_range("smallint")),
351        ("int4", Value::Int(value)) => i32::try_from(*value)
352            .ok()
353            .and_then(i32::checked_neg)
354            .map(|value| Value::Int(i64::from(value)))
355            .ok_or_else(|| out_of_range("integer")),
356        ("int8", Value::Int(value)) => value
357            .checked_neg()
358            .map(Value::Int)
359            .ok_or_else(|| out_of_range("bigint")),
360        ("float4" | "float8", Value::Float(value)) => Ok(Value::Float(-value)),
361        ("numeric", Value::Decimal(value)) => uqa_core::DecimalValue::from_i64(0)
362            .checked_sub(value)
363            .map(Value::Decimal)
364            .ok_or_else(|| out_of_range("numeric")),
365        (
366            "interval",
367            Value::Temporal(TemporalValue::Interval {
368                months,
369                days,
370                micros,
371            }),
372        ) => Ok(Value::Temporal(TemporalValue::Interval {
373            months: months
374                .checked_neg()
375                .ok_or_else(|| out_of_range("interval"))?,
376            days: days.checked_neg().ok_or_else(|| out_of_range("interval"))?,
377            micros: micros
378                .checked_neg()
379                .ok_or_else(|| out_of_range("interval"))?,
380        })),
381        _ => Err(SQLError::TypeMismatch(format!(
382            "operator does not exist: - {source}"
383        ))),
384    }
385}
386
387fn canonical_cast_source(source_ty: Option<&str>, value: &Value) -> String {
388    let source = source_ty.unwrap_or(match value {
389        Value::Str(_) | Value::FixedChar(_) => "unknown",
390        Value::Int(_) => "integer",
391        Value::Bool(_) => "boolean",
392        Value::Float(_) => "double precision",
393        Value::Decimal(_) => "numeric",
394        Value::Bytes(_) => "bytea",
395        Value::Temporal(TemporalValue::Interval { .. }) => "interval",
396        Value::Temporal(_) => "timestamp",
397        Value::Json(_) => "json",
398        Value::JsonB(_) => "jsonb",
399        Value::Array(_) => "anyarray",
400        Value::List(_) => "anyarray",
401        Value::Row(_) | Value::Record(_) => "record",
402        Value::Map(_) => "jsonb",
403        Value::Null => "unknown",
404        Value::Void => "void",
405    });
406    let (source, _) = split_type_modifier(source);
407    let source = source
408        .trim()
409        .to_ascii_lowercase()
410        .split_whitespace()
411        .collect::<Vec<_>>()
412        .join(" ");
413    let source = source.strip_prefix("pg_catalog.").unwrap_or(&source);
414    match source {
415        "smallint" | "int2" => "int2".into(),
416        "integer" | "int" | "int4" | "serial" | "serial4" => "int4".into(),
417        "bigint" | "int8" | "bigserial" | "serial8" => "int8".into(),
418        "character varying" | "varchar" => "varchar".into(),
419        "character" | "char" | "bpchar" => "bpchar".into(),
420        "boolean" | "bool" => "bool".into(),
421        "double" | "double precision" | "float8" => "float8".into(),
422        "real" | "float4" => "float4".into(),
423        other => other.into(),
424    }
425}
426
427fn undefined_cast(source: &str, target: &str) -> SQLError {
428    SQLError::Routine {
429        sqlstate: "42846".into(),
430        message: format!("cannot cast type {source} to {target}"),
431    }
432}
433
434fn postgres_type_display_name(name: &str) -> &str {
435    match name {
436        "int2" => "smallint",
437        "int4" => "integer",
438        "int8" => "bigint",
439        "float4" => "real",
440        "float8" => "double precision",
441        "bool" => "boolean",
442        "varchar" => "character varying",
443        "bpchar" => "character",
444        other => other,
445    }
446}
447
448fn cast_uuid(value: &Value) -> Result<Value> {
449    let text = match value {
450        Value::Str(text) | Value::FixedChar(text) => text,
451        other => {
452            return Err(SQLError::TypeMismatch(format!(
453                "cannot cast {other:?} to uuid"
454            )))
455        }
456    };
457    super::uuid::canonicalize_uuid(text).map(Value::Str)
458}
459
460/// Split `varchar(10)` / `numeric(10,2)` into `("varchar", Some("10"))`.
461pub(super) fn split_type_modifier(ty: &str) -> (&str, Option<&str>) {
462    match (ty.find('('), ty.rfind(')')) {
463        (Some(open), Some(close)) if close > open => {
464            (ty[..open].trim_end(), Some(&ty[open + 1..close]))
465        }
466        _ => (ty, None),
467    }
468}
469
470/// CAST to the integer family with `PostgreSQL` conversion rules:
471/// float8 rounds half-to-even, numeric rounds half-away-from-zero,
472/// strings must be integral text, and the result must fit the target
473/// width.
474pub(super) fn cast_integer(v: &Value, target: &str) -> Result<Value> {
475    let n: i64 = match v {
476        Value::Int(n) => *n,
477        Value::Bool(b) => i64::from(*b),
478        Value::Float(f) => {
479            if !f.is_finite() {
480                return Err(out_of_range(target));
481            }
482            let rounded = f.round_ties_even();
483            // `i64::MAX as f64` rounds up to 2^63.  Comparing with `>` would
484            // therefore admit 2^63 and Rust's float-to-int cast would silently
485            // saturate it to `i64::MAX`.
486            if rounded < i64::MIN as f64 || rounded >= 9_223_372_036_854_775_808.0 {
487                return Err(out_of_range(target));
488            }
489            rounded as i64
490        }
491        Value::Decimal(d) => d
492            .round_dp(0)
493            .to_i64_trunc()
494            .ok_or_else(|| out_of_range(target))?,
495        Value::Str(s) | Value::FixedChar(s) => {
496            s.trim().parse::<i64>().map_err(|_| SQLError::Routine {
497                sqlstate: "22P02".into(),
498                message: format!("invalid input syntax for type {target}: \"{s}\""),
499            })?
500        }
501        Value::Bytes(bytes) => bytea_to_integer(bytes, target)?,
502        other => {
503            return Err(SQLError::TypeMismatch(format!(
504                "cannot cast {other:?} to {target}"
505            )));
506        }
507    };
508    let in_range = match target {
509        "smallint" => i16::try_from(n).is_ok(),
510        "integer" => i32::try_from(n).is_ok(),
511        _ => true,
512    };
513    if !in_range {
514        return Err(out_of_range(target));
515    }
516    Ok(Value::Int(n))
517}
518
519/// CAST to boolean: strings follow `PostgreSQL`'s `parse_bool`
520/// (prefixes of true/false/yes/no, on/off, 1/0); numbers are non-zero
521/// tests.
522pub(super) fn cast_boolean(v: &Value) -> Result<Value> {
523    match v {
524        Value::Bool(b) => Ok(Value::Bool(*b)),
525        Value::Int(n) => Ok(Value::Bool(*n != 0)),
526        Value::Float(f) => Ok(Value::Bool(*f != 0.0)),
527        Value::Decimal(d) => Ok(Value::Bool(!d.is_zero())),
528        Value::Str(s) | Value::FixedChar(s) => {
529            let text = s.trim().to_ascii_lowercase();
530            let matches_prefix = |word: &str| !text.is_empty() && word.starts_with(&text);
531            let value = if matches_prefix("true") || matches_prefix("yes") || text == "1" {
532                Some(true)
533            } else if matches_prefix("false") || matches_prefix("no") || text == "0" {
534                Some(false)
535            } else if "on" == text {
536                Some(true)
537            } else if matches_prefix("off") && text.len() >= 2 {
538                Some(false)
539            } else {
540                None
541            };
542            value.map(Value::Bool).ok_or_else(|| SQLError::Routine {
543                sqlstate: "22P02".into(),
544                message: format!("invalid input syntax for type boolean: \"{s}\""),
545            })
546        }
547        other => Err(SQLError::TypeMismatch(format!(
548            "cannot cast {other:?} to boolean"
549        ))),
550    }
551}
552
553use array::cast_array_elements;
554pub use array::{array_dimensions, parse_pg_array_literal};
555use binary_oid::{
556    bytea_to_integer, cast_bytea, cast_oid, cast_regclass, cast_regnamespace, cast_regrole,
557    cast_xid,
558};
559use temporal::{cast_date, cast_temporal, TemporalCastTarget};
560
561#[cfg(test)]
562mod tests;