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