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 ColumnType::Domain { base, .. } = ty {
23        return format_postgres_text(value, base, engine);
24    }
25    if let Some(text) = format_regtype_value(value, ty, engine)? {
26        return Ok(text);
27    }
28    if matches!(ty, ColumnType::Int2Vector | ColumnType::OidVector) {
29        return vector_value_to_string(value)?
30            .ok_or_else(|| SQLError::Internal("invalid catalog vector result carrier".into()));
31    }
32    if let ColumnType::Array(element) = ty {
33        return format_array(value, element, engine);
34    }
35    if let (ColumnType::Composite(reference), Value::Record(fields)) = (ty, value) {
36        return format_record(fields, reference.oid, engine);
37    }
38    Ok(match value {
39        Value::Bool(value) => if *value { "t" } else { "f" }.into(),
40        Value::FixedChar(value) => value.clone(),
41        Value::Float(value) if matches!(ty, ColumnType::Real) => {
42            crate::expr::format_real(*value as f32)
43        }
44        Value::Float(value) => uqa_core::format_float_pg(*value),
45        Value::Enum(label) => {
46            crate::expr::enums::enum_label_text(engine.and_then(EngineHook::enum_labels), label)?
47        }
48        // Container output calls each enum field's output function, which reads the current label.
49        _ if crate::expr::enums::contains_enum_carrier(value) => {
50            value_to_string(&crate::expr::enums::render_enum_labels(
51                engine.and_then(EngineHook::enum_labels),
52                value,
53            )?)?
54        }
55        _ => value_to_string(value)?,
56    })
57}
58
59fn format_array(
60    value: &Value,
61    element: &ColumnType,
62    engine: Option<&dyn EngineHook>,
63) -> Result<String, SQLError> {
64    // SQL array type identity does not constrain value dimensions. Nested
65    // declarations still name the scalar element formatter at every depth.
66    let mut element = element;
67    while let ColumnType::Array(inner) = element {
68        element = inner;
69    }
70    let (values, prefix) = match value {
71        Value::Array(array) => array_parts(array),
72        Value::LegacyVector(vector) => array_parts(vector.as_array()),
73        Value::List(values) => (values.as_slice(), String::new()),
74        _ => return Err(SQLError::Internal("invalid array result carrier".into())),
75    };
76    let mut fields = Vec::with_capacity(values.len());
77    for value in values {
78        fields.push(match value {
79            Value::Null => "NULL".into(),
80            Value::List(_) | Value::Array(_) => format_array(value, element, engine)?,
81            _ => {
82                let text = format_postgres_text(value, element, engine)?;
83                if text.is_empty()
84                    || text.eq_ignore_ascii_case("null")
85                    || text.chars().any(|c| {
86                        c.is_ascii_whitespace() || matches!(c, ',' | '{' | '}' | '"' | '\\')
87                    })
88                {
89                    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
90                } else {
91                    text
92                }
93            }
94        });
95    }
96    Ok(format!("{prefix}{{{}}}", fields.join(",")))
97}
98
99/// `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.
100fn format_record(
101    fields: &[(String, Value)],
102    type_oid: u32,
103    engine: Option<&dyn EngineHook>,
104) -> Result<String, SQLError> {
105    let descriptor = crate::expr::composites::descriptor(
106        engine.and_then(EngineHook::composite_types),
107        type_oid,
108    )?;
109    let mut text = String::from("(");
110    for (index, (name, field)) in fields.iter().enumerate() {
111        if index != 0 {
112            text.push(',');
113        }
114        if matches!(field, Value::Null) {
115            continue;
116        }
117        let rendered = match descriptor.attribute(name) {
118            Some((_, attribute)) => format_postgres_text(field, &attribute.ty, engine)?,
119            None => format_postgres_text(
120                field,
121                &crate::type_resolution::value_type(field).unwrap_or(ColumnType::Text),
122                engine,
123            )?,
124        };
125        let quoted = rendered.is_empty()
126            || rendered.chars().any(|character| {
127                character.is_ascii_whitespace() || matches!(character, ',' | '(' | ')' | '"' | '\\')
128            });
129        if quoted {
130            text.push('"');
131            for character in rendered.chars() {
132                if matches!(character, '"' | '\\') {
133                    text.push(character);
134                }
135                text.push(character);
136            }
137            text.push('"');
138        } else {
139            text.push_str(&rendered);
140        }
141    }
142    text.push(')');
143    Ok(text)
144}
145
146fn array_parts(array: &uqa_core::ArrayValue) -> (&[Value], String) {
147    let mut prefix = String::new();
148    if !array.elements().is_empty() && array.lower_bounds().iter().any(|lower| *lower != 1) {
149        for (lower, length) in array.lower_bounds().iter().zip(array.dimensions()) {
150            let upper = i64::from(*lower) + *length as i64 - 1;
151            write!(prefix, "[{lower}:{upper}]").expect("writing to String cannot fail");
152        }
153        prefix.push('=');
154    }
155    (array.elements(), prefix)
156}