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
74pub fn catchable(error: &SQLError) -> bool {
75    !matches!(error, SQLError::Cancelled(_))
76}
77
78/// Message text exposed through SQLERRM: user-routine errors keep
79/// their raw message, engine errors keep their display form.
80pub fn routine_message(error: &SQLError) -> String {
81    match error {
82        SQLError::Routine { message, .. } => message.clone(),
83        other => other.to_string(),
84    }
85}
86
87pub fn looks_like_sqlstate(text: &str) -> bool {
88    text.len() == 5 && text.bytes().all(|b| b.is_ascii_alphanumeric())
89}
90
91/// Match an exception arm's condition list against a `SQLSTATE`.
92pub fn arm_matches(conditions: &[String], state: &str) -> Result<bool, SQLError> {
93    for condition in conditions {
94        if condition == "others" {
95            // WHEN OTHERS catches everything except QUERY_CANCELED
96            // and ASSERT_FAILURE, matching PostgreSQL.
97            if state != "57014" && state != "P0004" {
98                return Ok(true);
99            }
100            continue;
101        }
102        let mut known_condition = false;
103        for mapped in condition_sqlstates(condition) {
104            known_condition = true;
105            if sqlstate_matches(mapped, state) {
106                return Ok(true);
107            }
108        }
109        if !known_condition {
110            if looks_like_sqlstate(condition) {
111                if sqlstate_matches(&condition.to_ascii_uppercase(), state) {
112                    return Ok(true);
113                }
114            } else {
115                return Err(SQLError::Internal(format!(
116                    "unrecognized PL/pgSQL exception condition `{condition}`"
117                )));
118            }
119        }
120    }
121    Ok(false)
122}
123
124pub fn sqlstate_matches(condition: &str, state: &str) -> bool {
125    condition == state || (condition.ends_with("000") && state.get(..2) == condition.get(..2))
126}
127
128/// Substitute `%` placeholders in a RAISE format string.
129pub fn format_raise_message(format: &str, args: &[Value]) -> Result<String, SQLError> {
130    let mut out = String::with_capacity(format.len() + 16);
131    let mut chars = format.chars().peekable();
132    let mut next_arg = 0usize;
133    while let Some(c) = chars.next() {
134        if c != '%' {
135            out.push(c);
136            continue;
137        }
138        if chars.peek() == Some(&'%') {
139            chars.next();
140            out.push('%');
141            continue;
142        }
143        let Some(value) = args.get(next_arg) else {
144            return Err(SQLError::Routine {
145                sqlstate: "42601".into(),
146                message: "too few parameters specified for RAISE".into(),
147            });
148        };
149        next_arg += 1;
150        out.push_str(&raise_text(value));
151    }
152    if next_arg < args.len() {
153        return Err(SQLError::Routine {
154            sqlstate: "42601".into(),
155            message: "too many parameters specified for RAISE".into(),
156        });
157    }
158    Ok(out)
159}
160
161/// Text form of a value inside a RAISE message (`NULL` renders as
162/// `<NULL>`, booleans as `t` / `f`, arrays in brace form).
163pub fn raise_text(value: &Value) -> String {
164    match value {
165        Value::Null => "<NULL>".into(),
166        Value::Void => String::new(),
167        Value::Bool(b) => (if *b { "t" } else { "f" }).into(),
168        Value::Int(v) => v.to_string(),
169        Value::Float(v) => v.to_string(),
170        Value::Decimal(v) => v.to_sql_string(),
171        Value::Str(s) => s.clone(),
172        Value::FixedChar(s) => s.trim_end_matches(' ').to_string(),
173        Value::Temporal(t) => t.to_sql_string(),
174        Value::Json(text) | Value::JsonB(text) => text.clone(),
175        Value::Array(array) => crate::expr::array_value_to_string(array),
176        Value::Bytes(b) => {
177            use std::fmt::Write as _;
178            let mut out = String::with_capacity(2 + b.len() * 2);
179            out.push_str("\\x");
180            for byte in b {
181                let _ = write!(out, "{byte:02x}");
182            }
183            out
184        }
185        Value::List(items) => {
186            let inner = items.iter().map(raise_text).collect::<Vec<_>>().join(",");
187            format!("{{{inner}}}")
188        }
189        Value::Row(items) => {
190            let inner = items.iter().map(raise_text).collect::<Vec<_>>().join(",");
191            format!("({inner})")
192        }
193        Value::Record(fields) => {
194            let inner = fields
195                .iter()
196                .map(|(_, value)| raise_text(value))
197                .collect::<Vec<_>>()
198                .join(",");
199            format!("({inner})")
200        }
201        Value::Map(map) => serde_json::to_string(map).unwrap_or_else(|_| format!("{map:?}")),
202    }
203}
204
205#[cfg(test)]
206mod tests;