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