Skip to main content

uqa_client/
sql_diagnostic.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::SQLDiagnosticCategory;
12
13/// Bounded, statement-free SQL diagnostics shared by materialized and streamed errors.
14#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(try_from = "WireSQLDiagnostic", into = "WireSQLDiagnostic")]
16pub struct SQLDiagnostic {
17    sqlstate: Option<[u8; 5]>,
18    pub category: SQLDiagnosticCategory,
19    /// Zero-based index into the submitted batch; absent for transaction-boundary errors.
20    pub statement_index: Option<u32>,
21    /// One-based character position in the failing member; absent when unknown.
22    pub position: Option<u32>,
23}
24
25#[derive(Deserialize, Serialize)]
26#[serde(deny_unknown_fields)]
27struct WireSQLDiagnostic {
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    sqlstate: Option<String>,
30    category: SQLDiagnosticCategory,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    statement_index: Option<u32>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    position: Option<u32>,
35}
36
37impl SQLDiagnostic {
38    pub fn new(
39        sqlstate: Option<&str>,
40        category: SQLDiagnosticCategory,
41        statement_index: Option<u32>,
42        position: Option<u32>,
43    ) -> Self {
44        Self {
45            sqlstate: sqlstate.and_then(valid_sqlstate),
46            category,
47            statement_index,
48            position: position.filter(|value| *value != 0),
49        }
50    }
51
52    pub fn sqlstate(&self) -> Option<&str> {
53        self.sqlstate
54            .as_ref()
55            .and_then(|value| std::str::from_utf8(value).ok())
56    }
57
58    pub fn matches_code(&self, code: &str) -> bool {
59        code == "SQL_EXECUTION_FAILED"
60    }
61}
62
63impl TryFrom<WireSQLDiagnostic> for SQLDiagnostic {
64    type Error = &'static str;
65
66    fn try_from(wire: WireSQLDiagnostic) -> Result<Self, Self::Error> {
67        let sqlstate = wire
68            .sqlstate
69            .as_deref()
70            .map(|value| valid_sqlstate(value).ok_or("invalid SQLSTATE"))
71            .transpose()?;
72        if wire.position == Some(0) {
73            return Err("invalid SQL position");
74        }
75        Ok(Self {
76            sqlstate,
77            category: wire.category,
78            statement_index: wire.statement_index,
79            position: wire.position,
80        })
81    }
82}
83
84impl From<SQLDiagnostic> for WireSQLDiagnostic {
85    fn from(value: SQLDiagnostic) -> Self {
86        Self {
87            sqlstate: value.sqlstate().map(str::to_owned),
88            category: value.category,
89            statement_index: value.statement_index,
90            position: value.position,
91        }
92    }
93}
94
95fn valid_sqlstate(value: &str) -> Option<[u8; 5]> {
96    (value != "00000"
97        && value
98            .bytes()
99            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()))
100    .then(|| value.as_bytes().try_into().ok())
101    .flatten()
102}
103
104impl fmt::Display for SQLDiagnostic {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        write!(formatter, "{}", self.category.as_str())?;
107        if let Some(sqlstate) = self.sqlstate() {
108            write!(formatter, "; SQLSTATE {sqlstate}")?;
109        }
110        if let Some(index) = self.statement_index {
111            write!(
112                formatter,
113                "; batch statement {} (index {index})",
114                u64::from(index) + 1
115            )?;
116        }
117        if let Some(position) = self.position {
118            write!(formatter, "; character {position}")?;
119        }
120        if let Some(hint) = self.category.hint() {
121            write!(formatter, "; {hint}")?;
122        }
123        Ok(())
124    }
125}