Skip to main content

krishiv_sql/
sqlstate.rs

1#![forbid(unsafe_code)]
2//! SQLSTATE code mapping for Krishiv SQL errors.
3//!
4//! Maps [`SqlError`] variants to the 5-character SQLSTATE codes defined by
5//! ISO/IEC 9075 (SQL standard) and widely adopted by JDBC/ODBC drivers.
6//! Clients can surface these codes over the Flight SQL wire protocol in the
7//! `grpc-status-details` trailer.
8
9use crate::SqlError;
10
11// ── Well-known SQLSTATE codes ─────────────────────────────────────────────────
12
13/// `00000` — Successful completion.
14pub const SUCCESS: &str = "00000";
15/// `0A000` — Feature not supported.
16pub const FEATURE_NOT_SUPPORTED: &str = "0A000";
17/// `22000` — Data exception (general).
18pub const DATA_EXCEPTION: &str = "22000";
19/// `28000` — Invalid authorisation specification (access denied).
20pub const INVALID_AUTHORIZATION: &str = "28000";
21/// `42000` — Syntax error or access rule violation.
22pub const SYNTAX_ERROR: &str = "42000";
23/// `42501` — Insufficient privilege.
24pub const INSUFFICIENT_PRIVILEGE: &str = "42501";
25/// `42P01` — Undefined table.
26pub const UNDEFINED_TABLE: &str = "42P01";
27/// `57014` — Query cancelled (due to operator or timeout).
28pub const QUERY_CANCELLED: &str = "57014";
29/// `57P05` — Query execution timeout.
30pub const QUERY_TIMEOUT: &str = "57P05";
31/// `58000` — System error (external component failure).
32pub const SYSTEM_ERROR: &str = "58000";
33/// `XX000` — Internal error (engine fault).
34pub const INTERNAL_ERROR: &str = "XX000";
35/// `HY000` — General error (catch-all for driver-level errors).
36pub const GENERAL_ERROR: &str = "HY000";
37
38// ── Mapping ───────────────────────────────────────────────────────────────────
39
40/// Return the SQLSTATE code for the given [`SqlError`].
41///
42/// The returned string is always a 5-character SQLSTATE code conforming to
43/// ISO/IEC 9075.
44pub fn sqlstate_for(error: &SqlError) -> &'static str {
45    match error {
46        SqlError::EmptyQuery => SYNTAX_ERROR,
47        SqlError::EmptyTableName => SYNTAX_ERROR,
48        SqlError::Unsupported { .. } => FEATURE_NOT_SUPPORTED,
49        SqlError::InvalidTableFunction { .. } => SYNTAX_ERROR,
50        SqlError::DataFusion { message } => datafusion_sqlstate(message),
51        SqlError::Optimizer(_) => INTERNAL_ERROR,
52        SqlError::AccessDenied { .. } => INSUFFICIENT_PRIVILEGE,
53        SqlError::OperationCancelled { .. } => QUERY_CANCELLED,
54        SqlError::Timeout { .. } => QUERY_TIMEOUT,
55    }
56}
57
58/// Classify a `SqlError::DataFusion` message into a SQLSTATE.
59///
60/// Every DataFusion error used to map to `XX000` — "internal error (engine
61/// fault)". Most of them are nothing of the sort: an unknown table, a column
62/// typo, a type mismatch and a plain syntax error all arrive here, because
63/// `impl From<DataFusionError> for SqlError` keeps the rendered string and
64/// discards the variant. JDBC/ODBC clients key on SQLSTATE, so a user typo was
65/// reported to them as an engine bug.
66///
67/// The variant is recoverable from the message: DataFusion renders every error
68/// as `error_prefix() + message`, and those prefixes are fixed string literals
69/// in one `match` (`datafusion-common/src/error.rs`). Matching them is stable
70/// in a way that matching arbitrary message text would not be.
71///
72/// Anything unrecognised keeps `XX000`, so this can only ever be an
73/// improvement on the previous blanket mapping.
74fn datafusion_sqlstate(message: &str) -> &'static str {
75    // User errors in the statement itself.
76    if message.starts_with("SQL error: ") {
77        return SYNTAX_ERROR;
78    }
79    if message.starts_with("Error during planning: ") {
80        // `table '<ref>' not found` is the one planning failure with a
81        // dedicated code that clients act on differently.
82        return if message.contains("not found") || message.contains("No table named") {
83            UNDEFINED_TABLE
84        } else {
85            SYNTAX_ERROR
86        };
87    }
88    // "Schema error: No field named x" — an undefined column, i.e. the
89    // statement refers to something that does not exist.
90    if message.starts_with("Schema error: ") {
91        return SYNTAX_ERROR;
92    }
93    if message.starts_with("This feature is not implemented: ") {
94        return FEATURE_NOT_SUPPORTED;
95    }
96    // Bad data or a failed cast, not a broken engine.
97    if message.starts_with("Arrow error: ") {
98        return DATA_EXCEPTION;
99    }
100    // An external component failed: storage, network, a foreign library.
101    if message.starts_with("Parquet error: ")
102        || message.starts_with("Object Store error: ")
103        || message.starts_with("IO error: ")
104        || message.starts_with("External error: ")
105        || message.starts_with("FFI error: ")
106        || message.starts_with("Substrait error: ")
107    {
108        return SYSTEM_ERROR;
109    }
110    // Runtime conditions that are neither the statement's fault nor an engine
111    // defect — resource limits, misconfiguration, execution-time failures.
112    if message.starts_with("Resources exhausted: ")
113        || message.starts_with("Invalid or Unsupported Configuration: ")
114        || message.starts_with("Execution error: ")
115        || message.starts_with("ExecutionJoin error: ")
116    {
117        return GENERAL_ERROR;
118    }
119    // "Internal error: " and anything else: an engine fault.
120    INTERNAL_ERROR
121}
122
123/// A structured error envelope carrying the SQLSTATE code alongside the
124/// original error message.  Suitable for embedding in Flight SQL or JDBC
125/// error responses.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct SqlStateError {
128    /// 5-character SQLSTATE code.
129    pub code: &'static str,
130    /// Human-readable error message.
131    pub message: String,
132}
133
134impl SqlStateError {
135    /// Build a `SqlStateError` from a [`SqlError`].
136    pub fn from_sql_error(error: &SqlError) -> Self {
137        Self {
138            code: sqlstate_for(error),
139            message: error.to_string(),
140        }
141    }
142}
143
144impl std::fmt::Display for SqlStateError {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "SQLSTATE {} — {}", self.code, self.message)
147    }
148}
149
150impl std::error::Error for SqlStateError {}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn empty_query_maps_to_syntax_error() {
158        let e = SqlError::EmptyQuery;
159        assert_eq!(sqlstate_for(&e), SYNTAX_ERROR);
160    }
161
162    #[test]
163    fn unsupported_maps_to_feature_not_supported() {
164        let e = SqlError::Unsupported {
165            feature: "TABLESAMPLE".into(),
166        };
167        assert_eq!(sqlstate_for(&e), FEATURE_NOT_SUPPORTED);
168    }
169
170    #[test]
171    fn unrecognised_datafusion_message_keeps_internal_error() {
172        let e = SqlError::DataFusion {
173            message: "panic in executor".into(),
174        };
175        assert_eq!(sqlstate_for(&e), INTERNAL_ERROR);
176    }
177
178    /// A user's mistake must not be reported to a JDBC/ODBC client as an
179    /// engine fault. Every one of these used to map to `XX000`.
180    #[test]
181    fn datafusion_user_errors_do_not_map_to_internal_error() {
182        let cases: &[(&str, &str)] = &[
183            ("SQL error: ParserError(\"Expected: ...\")", SYNTAX_ERROR),
184            ("Error during planning: table 'orders' not found", UNDEFINED_TABLE),
185            ("Error during planning: No table named foo", UNDEFINED_TABLE),
186            ("Error during planning: Coercion from [Utf8] to ... failed", SYNTAX_ERROR),
187            ("Schema error: No field named custkey.", SYNTAX_ERROR),
188            ("This feature is not implemented: GROUPING SETS", FEATURE_NOT_SUPPORTED),
189            ("Arrow error: Cast error: Cannot cast 'x' to Int64", DATA_EXCEPTION),
190            ("Object Store error: Generic S3 error", SYSTEM_ERROR),
191            ("Parquet error: EOF", SYSTEM_ERROR),
192            ("IO error: broken pipe", SYSTEM_ERROR),
193            ("External error: connector failed", SYSTEM_ERROR),
194            ("Resources exhausted: memory limit", GENERAL_ERROR),
195            ("Execution error: divide by zero", GENERAL_ERROR),
196            ("Internal error: this is a bug", INTERNAL_ERROR),
197        ];
198        for (message, expected) in cases {
199            let error = SqlError::DataFusion {
200                message: (*message).to_string(),
201            };
202            assert_eq!(
203                sqlstate_for(&error),
204                *expected,
205                "wrong SQLSTATE for {message:?}"
206            );
207        }
208    }
209
210    /// The prefixes matched above are DataFusion's own, so they must survive a
211    /// real error round-tripping through `From<DataFusionError>`.
212    #[test]
213    fn prefixes_match_real_datafusion_errors() {
214        use datafusion::error::DataFusionError;
215
216        let planning: SqlError =
217            DataFusionError::Plan("table 'nope' not found".to_string()).into();
218        assert_eq!(sqlstate_for(&planning), UNDEFINED_TABLE);
219
220        let internal: SqlError = DataFusionError::Internal("bug".to_string()).into();
221        assert_eq!(sqlstate_for(&internal), INTERNAL_ERROR);
222
223        let exhausted: SqlError =
224            DataFusionError::ResourcesExhausted("pool".to_string()).into();
225        assert_eq!(sqlstate_for(&exhausted), GENERAL_ERROR);
226    }
227
228    #[test]
229    fn access_denied_maps_to_insufficient_privilege() {
230        let e = SqlError::AccessDenied {
231            reason: "no read permission".into(),
232        };
233        assert_eq!(sqlstate_for(&e), INSUFFICIENT_PRIVILEGE);
234    }
235
236    #[test]
237    fn cancelled_maps_to_query_cancelled() {
238        let e = SqlError::OperationCancelled { operation_id: 42 };
239        assert_eq!(sqlstate_for(&e), QUERY_CANCELLED);
240    }
241
242    #[test]
243    fn timeout_maps_to_query_timeout() {
244        let e = SqlError::Timeout { timeout_ms: 5000 };
245        assert_eq!(sqlstate_for(&e), QUERY_TIMEOUT);
246    }
247
248    #[test]
249    fn sql_state_error_display() {
250        let e = SqlError::EmptyQuery;
251        let se = SqlStateError::from_sql_error(&e);
252        let s = se.to_string();
253        assert!(s.contains(SYNTAX_ERROR));
254        assert!(s.contains("empty"));
255    }
256
257    #[test]
258    fn sql_state_error_is_std_error() {
259        let e = SqlError::EmptyQuery;
260        let se = SqlStateError::from_sql_error(&e);
261        let _: &dyn std::error::Error = &se;
262    }
263
264    #[test]
265    fn all_sqlstate_codes_are_5_chars() {
266        for code in &[
267            SUCCESS,
268            FEATURE_NOT_SUPPORTED,
269            DATA_EXCEPTION,
270            INVALID_AUTHORIZATION,
271            SYNTAX_ERROR,
272            INSUFFICIENT_PRIVILEGE,
273            UNDEFINED_TABLE,
274            QUERY_CANCELLED,
275            QUERY_TIMEOUT,
276            SYSTEM_ERROR,
277            INTERNAL_ERROR,
278            GENERAL_ERROR,
279        ] {
280            assert_eq!(code.len(), 5, "SQLSTATE {code} must be 5 characters");
281        }
282    }
283}