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::{
13    format_regtype_value_with_catalog_and_control, value_to_string, vector_value_to_string,
14    EngineHook, EngineValueCatalog, SQLValueCatalog,
15};
16use crate::SQLError;
17use uqa_core::{memory::ProductionControl, Value};
18
19/// Format a result with its live session output style and declared type.
20pub fn format_postgres_text(
21    value: &Value,
22    ty: &ColumnType,
23    engine: Option<&dyn EngineHook>,
24) -> Result<String, SQLError> {
25    let _interval_style = if crate::expr::interval_style::output_can_contain_interval(value) {
26        crate::expr::interval_style::live_scope(engine, &ProductionControl::uncontrolled())?
27    } else {
28        None
29    };
30    let catalog = engine.map(EngineValueCatalog);
31    format_postgres_text_with_catalog(
32        value,
33        ty,
34        catalog
35            .as_ref()
36            .map(|catalog| catalog as &dyn SQLValueCatalog),
37    )
38}
39
40/// Format an admitted value through borrowed type and output metadata, without requiring a stateful scalar execution hook.
41pub fn format_postgres_text_with_catalog(
42    value: &Value,
43    ty: &ColumnType,
44    catalog: Option<&dyn SQLValueCatalog>,
45) -> Result<String, SQLError> {
46    if let Value::Datum(datum) = value {
47        return format_postgres_text_with_catalog(
48            &*crate::expr::datums::read_with_value_catalog_and_control(
49                datum,
50                catalog,
51                &ProductionControl::uncontrolled(),
52            )?,
53            ty,
54            catalog,
55        );
56    }
57    if let ColumnType::Domain { base, .. } = ty {
58        return format_postgres_text_with_catalog(value, base, catalog);
59    }
60    if matches!(ty, ColumnType::InternalChar) {
61        let byte = crate::expr::internal_char::value_byte(value)
62            .ok_or_else(|| SQLError::Internal("invalid internal char result carrier".into()))?;
63        return crate::expr::internal_char::output_with_control(
64            byte,
65            &uqa_core::memory::ProductionControl::uncontrolled(),
66        )?
67        .into_uncontrolled()
68        .map_err(|_| SQLError::Internal("ordinary internal char output owner".into()));
69    }
70    if let Some(text) = format_regtype_value_with_catalog_and_control(
71        value,
72        ty,
73        catalog,
74        &ProductionControl::uncontrolled(),
75    )? {
76        return text
77            .into_uncontrolled()
78            .map_err(|_| SQLError::Internal("ordinary OID output owner".into()));
79    }
80    if matches!(ty, ColumnType::Int2Vector | ColumnType::OidVector) {
81        return vector_value_to_string(value)?
82            .ok_or_else(|| SQLError::Internal("invalid catalog vector result carrier".into()));
83    }
84    if let ColumnType::Array(element) = ty {
85        return format_array(value, element, catalog);
86    }
87    if let (ColumnType::Composite(reference), Value::Record(fields)) = (ty, value) {
88        return format_record(fields, fields.type_oid().unwrap_or(reference.oid), catalog);
89    }
90    Ok(match value {
91        Value::Bool(value) => if *value { "t" } else { "f" }.into(),
92        Value::FixedChar(value) => value.clone(),
93        Value::Float(value) if matches!(ty, ColumnType::Real) => {
94            crate::expr::format_real(*value as f32)
95        }
96        Value::Float(value) => uqa_core::format_float_pg(*value),
97        Value::Enum(label) => crate::expr::enums::enum_label_text(catalog, label)?,
98        // Container output calls each enum field's output function, which reads the current label.
99        _ if crate::expr::enums::contains_enum_carrier(value) => {
100            value_to_string(&crate::expr::enums::render_enum_labels(catalog, value)?)?
101        }
102        _ => value_to_string(value)?,
103    })
104}
105
106fn format_array(
107    value: &Value,
108    element: &ColumnType,
109    catalog: Option<&dyn SQLValueCatalog>,
110) -> Result<String, SQLError> {
111    // SQL array type identity does not constrain value dimensions. Nested
112    // declarations still name the scalar element formatter at every depth.
113    let mut element = element;
114    while let ColumnType::Array(inner) = element {
115        element = inner;
116    }
117    let catalog_element;
118    if let Value::Array(array) = value {
119        if let Some(oid) = array.element_type_oid() {
120            if let Some(actual) = crate::catalog::type_metadata::builtin_scalar_type(oid) {
121                element = actual;
122            } else if crate::catalog::type_metadata::pg_type_oid(element) != i64::from(oid) {
123                catalog_element = catalog
124                    .map(|catalog| catalog.value_type_by_oid(oid))
125                    .transpose()?
126                    .flatten()
127                    .ok_or_else(|| SQLError::Routine {
128                        sqlstate: "XX000".into(),
129                        message: format!("cache lookup failed for type {oid}"),
130                    })?;
131                element = &catalog_element;
132            }
133        }
134    }
135    let (values, prefix) = match value {
136        Value::Array(array) => array_parts(array),
137        Value::LegacyVector(vector) => array_parts(vector.as_array()),
138        Value::List(values) => (values.as_slice(), String::new()),
139        _ => return Err(SQLError::Internal("invalid array result carrier".into())),
140    };
141    let mut fields = Vec::with_capacity(values.len());
142    for value in values {
143        fields.push(match value {
144            Value::Null => "NULL".into(),
145            Value::List(_) | Value::Array(_) => format_array(value, element, catalog)?,
146            _ => {
147                let text = format_postgres_text_with_catalog(value, element, catalog)?;
148                if text.is_empty()
149                    || text.eq_ignore_ascii_case("null")
150                    || text.chars().any(|c| {
151                        c.is_ascii_whitespace() || matches!(c, ',' | '{' | '}' | '"' | '\\')
152                    })
153                {
154                    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
155                } else {
156                    text
157                }
158            }
159        });
160    }
161    Ok(format!("{prefix}{{{}}}", fields.join(",")))
162}
163
164/// `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.
165fn format_record(
166    fields: &[(String, Value)],
167    type_oid: u32,
168    catalog: Option<&dyn SQLValueCatalog>,
169) -> Result<String, SQLError> {
170    let descriptor = catalog
171        .ok_or_else(|| SQLError::Internal("composite type catalog is unavailable".into()))?
172        .value_composite_type(type_oid)?
173        .ok_or_else(|| SQLError::Routine {
174            sqlstate: "42704".into(),
175            message: format!("type with OID {type_oid} does not exist"),
176        })?;
177    let mut text = String::from("(");
178    for (index, (name, field)) in fields.iter().enumerate() {
179        if index != 0 {
180            text.push(',');
181        }
182        if matches!(field, Value::Null) {
183            continue;
184        }
185        let rendered = match descriptor.attribute(name) {
186            Some((_, attribute)) => {
187                format_postgres_text_with_catalog(field, &attribute.ty, catalog)?
188            }
189            None => format_postgres_text_with_catalog(
190                field,
191                &crate::type_resolution::value_type(field).unwrap_or(ColumnType::Text),
192                catalog,
193            )?,
194        };
195        let quoted = rendered.is_empty()
196            || rendered.chars().any(|character| {
197                character.is_ascii_whitespace() || matches!(character, ',' | '(' | ')' | '"' | '\\')
198            });
199        if quoted {
200            text.push('"');
201            for character in rendered.chars() {
202                if matches!(character, '"' | '\\') {
203                    text.push(character);
204                }
205                text.push(character);
206            }
207            text.push('"');
208        } else {
209            text.push_str(&rendered);
210        }
211    }
212    text.push(')');
213    Ok(text)
214}
215
216fn array_parts(array: &uqa_core::ArrayValue) -> (&[Value], String) {
217    let mut prefix = String::new();
218    if !array.elements().is_empty() && array.lower_bounds().iter().any(|lower| *lower != 1) {
219        for (lower, length) in array.lower_bounds().iter().zip(array.dimensions()) {
220            let upper = i64::from(*lower) + *length as i64 - 1;
221            write!(prefix, "[{lower}:{upper}]").expect("writing to String cannot fail");
222        }
223        prefix.push('=');
224    }
225    (array.elements(), prefix)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::cell::Cell;
232    use uqa_core::ArrayValue;
233
234    struct Catalog {
235        lookups: Cell<usize>,
236    }
237
238    fn domain() -> ColumnType {
239        ColumnType::Domain {
240            collation_oid: None,
241            schema: "public".into(),
242            name: "real_value".into(),
243            oid: 16_500,
244            array_oid: Some(16_501),
245            base: Box::new(ColumnType::Real),
246        }
247    }
248
249    impl EngineHook for Catalog {
250        fn nextval(&self, _: &str) -> Result<i64, SQLError> {
251            unreachable!()
252        }
253
254        fn currval(&self, _: &str) -> Result<i64, SQLError> {
255            unreachable!()
256        }
257
258        fn setval(&self, _: &str, _: i64, _: bool) -> Result<i64, SQLError> {
259            unreachable!()
260        }
261
262        fn resolve_type_oid(&self, oid: u32) -> Result<Option<ColumnType>, String> {
263            self.lookups.set(self.lookups.get() + 1);
264            Ok((oid == 16_500).then(domain))
265        }
266    }
267
268    #[test]
269    fn array_output_uses_original_catalog_element_and_reports_missing_identity() {
270        let catalog = Catalog {
271            lookups: Cell::new(0),
272        };
273        let value = Value::Array(
274            ArrayValue::try_new(vec![Value::Float(f64::from(0.1_f32)), Value::Null])
275                .unwrap()
276                .with_element_type_oid(Some(16_500)),
277        );
278        let changed = ColumnType::Array(Box::new(ColumnType::Text));
279        assert_eq!(
280            format_postgres_text(&value, &changed, Some(&catalog)).unwrap(),
281            "{0.1,NULL}"
282        );
283        assert_eq!(catalog.lookups.get(), 1);
284        assert_eq!(
285            format_postgres_text(
286                &value,
287                &ColumnType::Array(Box::new(domain())),
288                Some(&catalog),
289            )
290            .unwrap(),
291            "{0.1,NULL}"
292        );
293        assert_eq!(catalog.lookups.get(), 1);
294        let error = format_postgres_text(&value, &changed, None).unwrap_err();
295        assert_eq!(error.sqlstate(), Some("XX000"));
296        assert_eq!(error.to_string(), "cache lookup failed for type 16500");
297    }
298}