Skip to main content

uqa_sql/result/
text.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Typed `PostgreSQL` text output for result consumers.
8
9use std::fmt::Write;
10
11use crate::ast::ColumnType;
12use crate::expr::{format_regtype_value, value_to_string, vector_value_to_string, EngineHook};
13use crate::SQLError;
14use uqa_core::Value;
15
16/// Format a non-NULL result value using its declared type and optional catalog resolver. NULL remains separate from text in the calling result protocol.
17pub fn format_postgres_text(
18    value: &Value,
19    ty: &ColumnType,
20    engine: Option<&dyn EngineHook>,
21) -> Result<String, SQLError> {
22    if let Value::Datum(datum) = value {
23        return format_postgres_text(
24            &crate::expr::datums::read_with_catalog(datum, engine)?,
25            ty,
26            engine,
27        );
28    }
29    if let ColumnType::Domain { base, .. } = ty {
30        return format_postgres_text(value, base, engine);
31    }
32    if let Some(text) = format_regtype_value(value, ty, engine)? {
33        return Ok(text);
34    }
35    if matches!(ty, ColumnType::Int2Vector | ColumnType::OidVector) {
36        return vector_value_to_string(value)?
37            .ok_or_else(|| SQLError::Internal("invalid catalog vector result carrier".into()));
38    }
39    if let ColumnType::Array(element) = ty {
40        return format_array(value, element, engine);
41    }
42    if let (ColumnType::Composite(reference), Value::Record(fields)) = (ty, value) {
43        return format_record(fields, fields.type_oid().unwrap_or(reference.oid), engine);
44    }
45    Ok(match value {
46        Value::Bool(value) => if *value { "t" } else { "f" }.into(),
47        Value::FixedChar(value) => value.clone(),
48        Value::Float(value) if matches!(ty, ColumnType::Real) => {
49            crate::expr::format_real(*value as f32)
50        }
51        Value::Float(value) => uqa_core::format_float_pg(*value),
52        Value::Enum(label) => {
53            crate::expr::enums::enum_label_text(engine.and_then(EngineHook::enum_labels), label)?
54        }
55        // Container output calls each enum field's output function, which reads the current label.
56        _ if crate::expr::enums::contains_enum_carrier(value) => {
57            value_to_string(&crate::expr::enums::render_enum_labels(
58                engine.and_then(EngineHook::enum_labels),
59                value,
60            )?)?
61        }
62        _ => value_to_string(value)?,
63    })
64}
65
66fn format_array(
67    value: &Value,
68    element: &ColumnType,
69    engine: Option<&dyn EngineHook>,
70) -> Result<String, SQLError> {
71    // SQL array type identity does not constrain value dimensions. Nested
72    // declarations still name the scalar element formatter at every depth.
73    let mut element = element;
74    while let ColumnType::Array(inner) = element {
75        element = inner;
76    }
77    let catalog_element;
78    if let Value::Array(array) = value {
79        if let Some(oid) = array.element_type_oid() {
80            if let Some(actual) = crate::catalog::type_metadata::builtin_scalar_type(oid) {
81                element = actual;
82            } else if crate::catalog::type_metadata::pg_type_oid(element) != i64::from(oid) {
83                catalog_element = engine
84                    .map(|engine| engine.resolve_type_oid(oid))
85                    .transpose()
86                    .map_err(SQLError::Internal)?
87                    .flatten()
88                    .ok_or_else(|| SQLError::Routine {
89                        sqlstate: "XX000".into(),
90                        message: format!("cache lookup failed for type {oid}"),
91                    })?;
92                element = &catalog_element;
93            }
94        }
95    }
96    let (values, prefix) = match value {
97        Value::Array(array) => array_parts(array),
98        Value::LegacyVector(vector) => array_parts(vector.as_array()),
99        Value::List(values) => (values.as_slice(), String::new()),
100        _ => return Err(SQLError::Internal("invalid array result carrier".into())),
101    };
102    let mut fields = Vec::with_capacity(values.len());
103    for value in values {
104        fields.push(match value {
105            Value::Null => "NULL".into(),
106            Value::List(_) | Value::Array(_) => format_array(value, element, engine)?,
107            _ => {
108                let text = format_postgres_text(value, element, engine)?;
109                if text.is_empty()
110                    || text.eq_ignore_ascii_case("null")
111                    || text.chars().any(|c| {
112                        c.is_ascii_whitespace() || matches!(c, ',' | '{' | '}' | '"' | '\\')
113                    })
114                {
115                    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
116                } else {
117                    text
118                }
119            }
120        });
121    }
122    Ok(format!("{prefix}{{{}}}", fields.join(",")))
123}
124
125/// `record_out`: each field through its attribute type's output function, an empty field for NULL, and double quotes around a field that is empty or holds a separator, parenthesis, quote, backslash or whitespace, doubling quotes and backslashes inside.
126fn format_record(
127    fields: &[(String, Value)],
128    type_oid: u32,
129    engine: Option<&dyn EngineHook>,
130) -> Result<String, SQLError> {
131    let descriptor = crate::expr::composites::descriptor(
132        engine.and_then(EngineHook::composite_types),
133        type_oid,
134    )?;
135    let mut text = String::from("(");
136    for (index, (name, field)) in fields.iter().enumerate() {
137        if index != 0 {
138            text.push(',');
139        }
140        if matches!(field, Value::Null) {
141            continue;
142        }
143        let rendered = match descriptor.attribute(name) {
144            Some((_, attribute)) => format_postgres_text(field, &attribute.ty, engine)?,
145            None => format_postgres_text(
146                field,
147                &crate::type_resolution::value_type(field).unwrap_or(ColumnType::Text),
148                engine,
149            )?,
150        };
151        let quoted = rendered.is_empty()
152            || rendered.chars().any(|character| {
153                character.is_ascii_whitespace() || matches!(character, ',' | '(' | ')' | '"' | '\\')
154            });
155        if quoted {
156            text.push('"');
157            for character in rendered.chars() {
158                if matches!(character, '"' | '\\') {
159                    text.push(character);
160                }
161                text.push(character);
162            }
163            text.push('"');
164        } else {
165            text.push_str(&rendered);
166        }
167    }
168    text.push(')');
169    Ok(text)
170}
171
172fn array_parts(array: &uqa_core::ArrayValue) -> (&[Value], String) {
173    let mut prefix = String::new();
174    if !array.elements().is_empty() && array.lower_bounds().iter().any(|lower| *lower != 1) {
175        for (lower, length) in array.lower_bounds().iter().zip(array.dimensions()) {
176            let upper = i64::from(*lower) + *length as i64 - 1;
177            write!(prefix, "[{lower}:{upper}]").expect("writing to String cannot fail");
178        }
179        prefix.push('=');
180    }
181    (array.elements(), prefix)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::cell::Cell;
188    use uqa_core::ArrayValue;
189
190    struct Catalog {
191        lookups: Cell<usize>,
192    }
193
194    fn domain() -> ColumnType {
195        ColumnType::Domain {
196            schema: "public".into(),
197            name: "real_value".into(),
198            oid: 16_500,
199            array_oid: Some(16_501),
200            base: Box::new(ColumnType::Real),
201        }
202    }
203
204    impl EngineHook for Catalog {
205        fn nextval(&self, _: &str) -> Result<i64, SQLError> {
206            unreachable!()
207        }
208
209        fn currval(&self, _: &str) -> Result<i64, SQLError> {
210            unreachable!()
211        }
212
213        fn setval(&self, _: &str, _: i64, _: bool) -> Result<i64, SQLError> {
214            unreachable!()
215        }
216
217        fn resolve_type_oid(&self, oid: u32) -> Result<Option<ColumnType>, String> {
218            self.lookups.set(self.lookups.get() + 1);
219            Ok((oid == 16_500).then(domain))
220        }
221    }
222
223    #[test]
224    fn array_output_uses_original_catalog_element_and_reports_missing_identity() {
225        let catalog = Catalog {
226            lookups: Cell::new(0),
227        };
228        let value = Value::Array(
229            ArrayValue::try_new(vec![Value::Float(f64::from(0.1_f32)), Value::Null])
230                .unwrap()
231                .with_element_type_oid(Some(16_500)),
232        );
233        let changed = ColumnType::Array(Box::new(ColumnType::Text));
234        assert_eq!(
235            format_postgres_text(&value, &changed, Some(&catalog)).unwrap(),
236            "{0.1,NULL}"
237        );
238        assert_eq!(catalog.lookups.get(), 1);
239        assert_eq!(
240            format_postgres_text(
241                &value,
242                &ColumnType::Array(Box::new(domain())),
243                Some(&catalog),
244            )
245            .unwrap(),
246            "{0.1,NULL}"
247        );
248        assert_eq!(catalog.lookups.get(), 1);
249        let error = format_postgres_text(&value, &changed, None).unwrap_err();
250        assert_eq!(error.sqlstate(), Some("XX000"));
251        assert_eq!(error.to_string(), "cache lookup failed for type 16500");
252    }
253}