Skip to main content

uqa_sql/plpgsql/
runtime_diagnostics.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! SQLSTATE matching, row diagnostics, and `RAISE` formatting.
8
9use crate::{expr::cast_value, plpgsql::condition_sqlstates, SQLError, SQLResult};
10use uqa_core::Value;
11
12pub fn return_query_context_error() -> SQLError {
13    SQLError::Routine {
14        sqlstate: "42601".into(),
15        message: "cannot use RETURN QUERY in a non-SETOF function".into(),
16    }
17}
18
19/// Copy one result row in declared output-column order. Positional result storage is authoritative when labels repeat; named rows are only the public compatibility carrier used by [`SQLResult::value_at`]'s final fallback.
20pub fn result_row_values(result: &SQLResult, row: usize) -> Option<Vec<Value>> {
21    (row < result.rows.len()).then(|| {
22        (0..result.columns.len())
23            .map(|column| result.value_at(row, column).cloned().unwrap_or(Value::Null))
24            .collect()
25    })
26}
27
28pub fn result_row_count(result: &SQLResult) -> Result<i64, SQLError> {
29    let (raw_count, source) = if result.columns.is_empty() {
30        (result.affected_rows, "affected-row")
31    } else {
32        (
33            u64::try_from(result.rows.len()).map_err(|_| {
34                SQLError::Internal(format!(
35                    "result row count {} cannot be represented as u64",
36                    result.rows.len()
37                ))
38            })?,
39            "result-row",
40        )
41    };
42    i64::try_from(raw_count).map_err(|_| {
43        SQLError::Internal(format!(
44            "{source} count {raw_count} exceeds PL/pgSQL's signed 64-bit ROW_COUNT range"
45        ))
46    })
47}
48
49pub fn strict_into_check(row_count: i64) -> Result<(), SQLError> {
50    if row_count == 0 {
51        return Err(SQLError::Routine {
52            sqlstate: "P0002".into(),
53            message: "query returned no rows".into(),
54        });
55    }
56    if row_count > 1 {
57        return Err(SQLError::Routine {
58            sqlstate: "P0003".into(),
59            message: "query returned more than one row".into(),
60        });
61    }
62    Ok(())
63}
64
65pub fn to_i64_value(value: &Value) -> Result<i64, SQLError> {
66    match cast_value(value, "bigint")? {
67        Value::Int(v) => Ok(v),
68        other => Err(SQLError::TypeMismatch(format!(
69            "expected an integer, got {other:?}"
70        ))),
71    }
72}
73
74/// Message text exposed through SQLERRM: user-routine errors keep
75/// their raw message, engine errors keep their display form.
76pub fn routine_message(error: &SQLError) -> String {
77    match error {
78        SQLError::Routine { message, .. } => message.clone(),
79        other => other.to_string(),
80    }
81}
82
83pub fn looks_like_sqlstate(text: &str) -> bool {
84    text.len() == 5 && text.bytes().all(|b| b.is_ascii_alphanumeric())
85}
86
87/// Match an exception arm's condition list against a `SQLSTATE`.
88pub fn arm_matches(conditions: &[String], state: &str) -> Result<bool, SQLError> {
89    for condition in conditions {
90        if condition == "others" {
91            // WHEN OTHERS catches everything except QUERY_CANCELED
92            // and ASSERT_FAILURE, matching PostgreSQL.
93            if state != "57014" && state != "P0004" {
94                return Ok(true);
95            }
96            continue;
97        }
98        let mut known_condition = false;
99        for mapped in condition_sqlstates(condition) {
100            known_condition = true;
101            if sqlstate_matches(mapped, state) {
102                return Ok(true);
103            }
104        }
105        if !known_condition {
106            if looks_like_sqlstate(condition) {
107                if sqlstate_matches(&condition.to_ascii_uppercase(), state) {
108                    return Ok(true);
109                }
110            } else {
111                return Err(SQLError::Internal(format!(
112                    "unrecognized PL/pgSQL exception condition `{condition}`"
113                )));
114            }
115        }
116    }
117    Ok(false)
118}
119
120pub fn sqlstate_matches(condition: &str, state: &str) -> bool {
121    condition == state || (condition.ends_with("000") && state.get(..2) == condition.get(..2))
122}
123
124/// Substitute `%` placeholders in a RAISE format string.
125pub fn format_raise_message(format: &str, args: &[Value]) -> Result<String, SQLError> {
126    let mut out = String::with_capacity(format.len() + 16);
127    let mut chars = format.chars().peekable();
128    let mut next_arg = 0usize;
129    while let Some(c) = chars.next() {
130        if c != '%' {
131            out.push(c);
132            continue;
133        }
134        if chars.peek() == Some(&'%') {
135            chars.next();
136            out.push('%');
137            continue;
138        }
139        let Some(value) = args.get(next_arg) else {
140            return Err(SQLError::Routine {
141                sqlstate: "42601".into(),
142                message: "too few parameters specified for RAISE".into(),
143            });
144        };
145        next_arg += 1;
146        out.push_str(&raise_text(value)?);
147    }
148    if next_arg < args.len() {
149        return Err(SQLError::Routine {
150            sqlstate: "42601".into(),
151            message: "too many parameters specified for RAISE".into(),
152        });
153    }
154    Ok(out)
155}
156
157/// Text form of a value inside a RAISE message (`NULL` renders as
158/// `<NULL>`, booleans as `t` / `f`, arrays in brace form).
159pub fn raise_text(value: &Value) -> Result<String, SQLError> {
160    Ok(match value {
161        Value::Null => "<NULL>".into(),
162        Value::Void => String::new(),
163        Value::Bool(b) => (if *b { "t" } else { "f" }).into(),
164        Value::Int(v) => v.to_string(),
165        Value::Float(v) => v.to_string(),
166        Value::Decimal(v) => v.to_sql_string(),
167        Value::Str(s) => s.clone(),
168        Value::FixedChar(s) => s.trim_end_matches(' ').to_string(),
169        Value::Temporal(t) => t.to_sql_string(),
170        Value::Json(text) | Value::JsonB(text) => text.clone(),
171        Value::Array(_) | Value::LegacyVector(_) => crate::expr::value_to_string(value)?,
172        Value::Enum(value) => return Err(crate::expr::catalog_output_required(value)),
173        Value::Bytes(b) => {
174            use std::fmt::Write as _;
175            let mut out = String::with_capacity(2 + b.len() * 2);
176            out.push_str("\\x");
177            for byte in b {
178                let _ = write!(out, "{byte:02x}");
179            }
180            out
181        }
182        Value::List(items) => {
183            let inner = items
184                .iter()
185                .map(raise_text)
186                .collect::<Result<Vec<_>, _>>()?
187                .join(",");
188            format!("{{{inner}}}")
189        }
190        Value::Row(items) => {
191            let inner = items
192                .iter()
193                .map(raise_text)
194                .collect::<Result<Vec<_>, _>>()?
195                .join(",");
196            format!("({inner})")
197        }
198        Value::Record(fields) => {
199            let inner = fields
200                .iter()
201                .map(|(_, value)| raise_text(value))
202                .collect::<Result<Vec<_>, _>>()?
203                .join(",");
204            format!("({inner})")
205        }
206        Value::Map(map) => serde_json::to_string(map).unwrap_or_else(|_| format!("{map:?}")),
207    })
208}
209
210#[cfg(test)]
211mod tests;