Skip to main content

uqa_sql/expr/
conversion.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Scalar coercion, checked numeric conversion, and vector/tensor decoding.
8
9use super::{
10    hex_encode, out_of_range, value_to_json, ArrayValue, DecimalValue, Result, SQLError, Value,
11};
12
13pub fn value_to_string(v: &Value) -> String {
14    match v {
15        Value::Null => "".into(),
16        Value::Int(i) => i.to_string(),
17        Value::Float(f) => f.to_string(),
18        Value::Decimal(d) => d.to_sql_string(),
19        Value::Str(s) => s.clone(),
20        Value::FixedChar(s) => s.trim_end_matches(' ').to_string(),
21        Value::Bool(b) => (if *b { "true" } else { "false" }).into(),
22        Value::Temporal(t) => t.to_sql_string(),
23        Value::Json(text) | Value::JsonB(text) => text.clone(),
24        Value::Array(array) => array_value_to_string(array),
25        Value::List(_) | Value::Map(_) => value_to_json(v).to_string(),
26        Value::Row(values) => composite_value_to_string(values.iter()),
27        Value::Record(fields) => composite_value_to_string(fields.iter().map(|(_, value)| value)),
28        // bytea renders as PostgreSQL hex output in text contexts.
29        Value::Bytes(b) => format!("\\x{}", hex_encode(b)),
30    }
31}
32
33pub fn array_value_to_string(array: &ArrayValue) -> String {
34    let dimensions = if array
35        .lower_bounds()
36        .iter()
37        .any(|lower_bound| *lower_bound != 1)
38    {
39        array
40            .lower_bounds()
41            .iter()
42            .zip(array.dimensions())
43            .map(|(lower, length)| {
44                let upper = i64::from(*lower) + i64::try_from(*length).unwrap_or(i64::MAX) - 1;
45                format!("[{lower}:{upper}]")
46            })
47            .collect::<String>()
48            + "="
49    } else {
50        String::new()
51    };
52    format!("{dimensions}{}", array_elements_to_string(array.elements()))
53}
54
55fn array_elements_to_string(elements: &[Value]) -> String {
56    let rendered = elements
57        .iter()
58        .map(|value| match value {
59            Value::Null => "NULL".to_string(),
60            Value::Bool(value) => if *value { "t" } else { "f" }.to_string(),
61            Value::List(nested) => array_elements_to_string(nested),
62            Value::Array(nested) => array_value_to_string(nested),
63            other => {
64                let text = value_to_string(other);
65                let requires_quotes = text.is_empty()
66                    || text.eq_ignore_ascii_case("null")
67                    || text.chars().any(|character| {
68                        character.is_whitespace()
69                            || matches!(character, ',' | '{' | '}' | '"' | '\\')
70                    });
71                if requires_quotes {
72                    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
73                } else {
74                    text
75                }
76            }
77        })
78        .collect::<Vec<_>>();
79    format!("{{{}}}", rendered.join(","))
80}
81
82fn composite_value_to_string<'a>(values: impl IntoIterator<Item = &'a Value>) -> String {
83    let fields = values
84        .into_iter()
85        .map(|value| {
86            if matches!(value, Value::Null) {
87                return String::new();
88            }
89            let text = match value {
90                Value::Bool(true) => "t".to_string(),
91                Value::Bool(false) => "f".to_string(),
92                other => value_to_string(other),
93            };
94            if text.is_empty()
95                || text.bytes().any(|byte| {
96                    matches!(byte, b',' | b'(' | b')' | b'"' | b'\\') || byte.is_ascii_whitespace()
97                })
98            {
99                format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\"\""))
100            } else {
101                text
102            }
103        })
104        .collect::<Vec<_>>();
105    format!("({})", fields.join(","))
106}
107
108pub(super) fn expect_str(args: &[Value], idx: usize) -> Result<String> {
109    args.get(idx)
110        .map(value_to_string)
111        .ok_or_else(|| SQLError::TypeMismatch(format!("missing arg #{idx}")))
112}
113
114pub(super) fn string1<F: FnOnce(&str) -> String>(args: &[Value], f: F) -> Result<Value> {
115    if args.is_empty() {
116        return Err(SQLError::TypeMismatch("string fn needs 1 arg".into()));
117    }
118    if matches!(args[0], Value::Null) {
119        return Ok(Value::Null);
120    }
121    let s = value_to_string(&args[0]);
122    Ok(Value::Str(f(&s)))
123}
124
125pub(super) fn float1<F: FnOnce(f64) -> f64>(args: &[Value], name: &str, f: F) -> Result<Value> {
126    if args.len() != 1 {
127        return Err(SQLError::TypeMismatch(format!("{name} takes 1 arg")));
128    }
129    if matches!(args[0], Value::Null) {
130        return Ok(Value::Null);
131    }
132    Ok(Value::Float(f(to_f64(&args[0])?)))
133}
134
135pub(super) fn initcap_str(s: &str) -> String {
136    let mut out = String::with_capacity(s.len());
137    let mut start = true;
138    for ch in s.chars() {
139        if ch.is_whitespace() {
140            out.push(ch);
141            start = true;
142            continue;
143        }
144        if start {
145            for c in ch.to_uppercase() {
146                out.push(c);
147            }
148            start = false;
149        } else {
150            for c in ch.to_lowercase() {
151                out.push(c);
152            }
153        }
154    }
155    out
156}
157
158pub(super) fn to_i64(v: &Value) -> Result<i64> {
159    match v {
160        Value::Int(n) => Ok(*n),
161        Value::Float(f) => float_to_i64_trunc(*f),
162        Value::Decimal(d) => d
163            .to_i64_trunc()
164            .ok_or_else(|| SQLError::TypeMismatch(format!("cannot cast {v:?} to integer"))),
165        Value::Bool(b) => Ok(i64::from(*b)),
166        Value::Str(s) | Value::FixedChar(s) => s
167            .trim()
168            .parse()
169            .map_err(|_| SQLError::TypeMismatch(format!("cannot parse {s:?} as integer"))),
170        other => Err(SQLError::TypeMismatch(format!(
171            "expected integer, got {other:?}"
172        ))),
173    }
174}
175
176pub(super) fn nonnegative_usize(value: i64, label: &str) -> Result<usize> {
177    usize::try_from(value).map_err(|_| SQLError::Routine {
178        sqlstate: "22003".into(),
179        message: format!("{label} exceeds the platform addressable range"),
180    })
181}
182
183pub(super) fn allocation_error(label: &str) -> SQLError {
184    SQLError::Routine {
185        sqlstate: "53200".into(),
186        message: format!("{label} result exceeds available memory"),
187    }
188}
189
190pub(crate) fn to_f64(v: &Value) -> Result<f64> {
191    match v {
192        Value::Int(n) => Ok(*n as f64),
193        Value::Float(f) => Ok(*f),
194        Value::Decimal(d) => d.to_f64().ok_or_else(|| {
195            SQLError::TypeMismatch(format!("cannot cast {v:?} to double precision"))
196        }),
197        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
198        // float8 casts accept PostgreSQL's textual forms, including
199        // Infinity / NaN spellings.
200        Value::Str(s) | Value::FixedChar(s) => {
201            let text = s.trim();
202            let lowered = text.to_ascii_lowercase();
203            match lowered.as_str() {
204                "infinity" | "inf" | "+infinity" | "+inf" => Ok(f64::INFINITY),
205                "-infinity" | "-inf" => Ok(f64::NEG_INFINITY),
206                "nan" => Ok(f64::NAN),
207                _ => text.parse().map_err(|_| SQLError::Routine {
208                    sqlstate: "22P02".into(),
209                    message: format!("invalid input syntax for type double precision: \"{s}\""),
210                }),
211            }
212        }
213        other => Err(SQLError::TypeMismatch(format!(
214            "expected number, got {other:?}"
215        ))),
216    }
217}
218
219pub(super) fn to_decimal(v: &Value) -> Result<DecimalValue> {
220    match v {
221        Value::Decimal(d) => Ok(d.clone()),
222        Value::Int(n) => Ok(DecimalValue::from_i64(*n)),
223        Value::Float(f) => DecimalValue::from_f64_lossy(*f)
224            .ok_or_else(|| SQLError::TypeMismatch(format!("cannot cast {v:?} to numeric"))),
225        Value::Bool(b) => Ok(DecimalValue::from_bool(*b)),
226        Value::Str(s) | Value::FixedChar(s) => {
227            DecimalValue::parse(s).ok_or_else(|| SQLError::Routine {
228                sqlstate: "22P02".into(),
229                message: format!("invalid input syntax for type numeric: \"{s}\""),
230            })
231        }
232        other => Err(SQLError::TypeMismatch(format!(
233            "expected number, got {other:?}"
234        ))),
235    }
236}
237
238pub(super) fn float_to_i64_trunc(value: f64) -> Result<i64> {
239    if !value.is_finite() || value < i64::MIN as f64 || value >= 9_223_372_036_854_775_808.0 {
240        return Err(out_of_range("bigint"));
241    }
242    Ok(value.trunc() as i64)
243}
244
245pub(super) fn float_to_i64_rounded(value: f64, type_name: &str) -> Result<i64> {
246    let rounded = value.round();
247    if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded >= 9_223_372_036_854_775_808.0 {
248        return Err(out_of_range(type_name));
249    }
250    Ok(rounded as i64)
251}
252
253pub(super) fn gcd_i64(a: i64, b: i64) -> Result<i64> {
254    let mut a = a.unsigned_abs();
255    let mut b = b.unsigned_abs();
256    while b != 0 {
257        let r = a % b;
258        a = b;
259        b = r;
260    }
261    i64::try_from(a).map_err(|_| out_of_range("bigint"))
262}
263
264/// Best-effort `Value -> i64`. Returns `None` for shapes that do not
265/// have a well-defined integer projection (e.g. `Value::Null`).
266pub(super) fn coerce_i64(v: &Value) -> Option<i64> {
267    match v {
268        Value::Int(n) => Some(*n),
269        Value::Float(f) => float_to_i64_trunc(*f).ok(),
270        Value::Decimal(d) => d.to_i64_trunc(),
271        Value::Bool(b) => Some(i64::from(*b)),
272        Value::Str(s) | Value::FixedChar(s) => s.parse().ok(),
273        _ => None,
274    }
275}
276
277/// Coerce a [`Value`] into a `Vec<f32>` if it is a homogeneous numeric
278/// list (used to read vector literals from `ARRAY[...]` or `$N` Vector
279/// params).
280pub fn value_to_vector(v: &Value) -> Result<Vec<f32>> {
281    let items = match v {
282        Value::List(items) => items.as_slice(),
283        Value::Array(array) if array.dimensions().len() <= 1 => array.elements(),
284        Value::Array(array) => {
285            return Err(SQLError::TypeMismatch(format!(
286                "expected one-dimensional vector input, got {} dimensions",
287                array.dimensions().len()
288            )))
289        }
290        other => {
291            return Err(SQLError::TypeMismatch(format!(
292                "expected vector (numeric array), got {other:?}"
293            )))
294        }
295    };
296    {
297        let mut out = Vec::with_capacity(items.len());
298        for item in items {
299            let x = match item {
300                Value::Float(f) => numeric_f64_to_f32(*f, item)?,
301                Value::Int(i) => *i as f32,
302                Value::Decimal(d) => numeric_f64_to_f32(
303                    d.to_f64().ok_or_else(|| {
304                        SQLError::TypeMismatch(format!("vector element must fit f32, got {item:?}"))
305                    })?,
306                    item,
307                )?,
308                other => {
309                    return Err(SQLError::TypeMismatch(format!(
310                        "vector element must be numeric, got {other:?}"
311                    )))
312                }
313            };
314            out.push(x);
315        }
316        Ok(out)
317    }
318}
319
320pub(super) fn numeric_f64_to_f32(value: f64, source: &Value) -> Result<f32> {
321    if !value.is_finite() || value < -(f32::MAX as f64) || value > f32::MAX as f64 {
322        return Err(SQLError::TypeMismatch(format!(
323            "vector element must be finite and fit f32, got {source:?}"
324        )));
325    }
326    Ok(value as f32)
327}
328
329/// Coerce a [`Value`] into a tensor: an array of homogeneous numeric
330/// vectors. Used by `TENSOR(N)` columns to store chunk embeddings for one
331/// row while still indexing each vector element.
332pub fn value_to_tensor(v: &Value) -> Result<Vec<Vec<f32>>> {
333    let items = match v {
334        Value::List(items) => items.as_slice(),
335        Value::Array(array) if array.dimensions().is_empty() || array.dimensions().len() == 2 => {
336            array.elements()
337        }
338        Value::Array(array) => {
339            return Err(SQLError::TypeMismatch(format!(
340                "expected two-dimensional tensor input, got {} dimensions",
341                array.dimensions().len()
342            )))
343        }
344        other => {
345            return Err(SQLError::TypeMismatch(format!(
346                "expected tensor (array of numeric arrays), got {other:?}"
347            )))
348        }
349    };
350    {
351        let mut out = Vec::with_capacity(items.len());
352        for item in items {
353            out.push(value_to_vector(item)?);
354        }
355        Ok(out)
356    }
357}