hyperdb_compile_check/error_extract.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SQLSTATE-based Hyper error classification.
5//!
6//! Phase 0 spike S5 confirmed that Hyper returns PostgreSQL SQLSTATE codes as a
7//! structured field on `Error::Server`. We branch on the **stable code**, not
8//! fragile message text, so Hyper message wording changes don't break us.
9//!
10//! Relevant codes:
11//! - `42P01` — undefined_table → extract the table name and seed-and-retry
12//! - `42703` — undefined_column → report the column name verbatim
13//! - `42601` — syntax_error → forward the message verbatim
14//! - anything else → forward as `HyperError`
15
16use hyperdb_api::Error;
17
18/// Classification of a Hyper error for compile-time validation purposes.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ErrorClass {
21 /// SQLSTATE `42P01`: a table is missing. Contains the table name extracted
22 /// from the error message.
23 MissingTable(String),
24 /// SQLSTATE `42703`: a column is missing. Contains the identifier verbatim.
25 MissingColumn(String),
26 /// SQLSTATE `42601`: SQL syntax error. The full message is forwarded.
27 SyntaxError(String),
28 /// Any other error (e.g. connection failure, internal error).
29 Other(String),
30}
31
32/// Classify a `hyperdb_api::Error` by its SQLSTATE code.
33pub fn classify(err: &Error) -> ErrorClass {
34 match err.sqlstate() {
35 Some("42P01") => {
36 ErrorClass::MissingTable(extract_quoted_identifier(&format!("{err}"), "table"))
37 }
38 Some("42703") => {
39 ErrorClass::MissingColumn(extract_quoted_identifier(&format!("{err}"), "column"))
40 }
41 Some("42601") => ErrorClass::SyntaxError(format!("{err}")),
42 _ => ErrorClass::Other(format!("{err}")),
43 }
44}
45
46/// Extract a double-quoted or single-quoted identifier from a Hyper error
47/// message, with a fallback to the full message.
48///
49/// Phase 0 output for 42P01: `ERROR: table "ghosts" does not exist (42P01)`
50/// Phase 0 output for 42703: `ERROR: unknown column 'ema1l' (42703)`
51fn extract_quoted_identifier(message: &str, _kind: &str) -> String {
52 // Try double-quoted first ("ghosts"), then single-quoted ('ema1l').
53 if let Some(name) = extract_between(message, '"', '"') {
54 return name;
55 }
56 if let Some(name) = extract_between(message, '\'', '\'') {
57 return name;
58 }
59 // Fallback: return the whole message so we never lose information.
60 message.to_owned()
61}
62
63fn extract_between(s: &str, open: char, close: char) -> Option<String> {
64 let start = s.find(open)? + open.len_utf8();
65 let end = s[start..].find(close)? + start;
66 Some(s[start..end].to_owned())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn extract_double_quoted() {
75 let msg = r#"ERROR: table "ghosts" does not exist (42P01)"#;
76 assert_eq!(extract_quoted_identifier(msg, "table"), "ghosts");
77 }
78
79 #[test]
80 fn extract_single_quoted() {
81 let msg = "ERROR: unknown column 'ema1l' (42703)";
82 assert_eq!(extract_quoted_identifier(msg, "column"), "ema1l");
83 }
84
85 #[test]
86 fn extract_falls_back_to_full_message() {
87 let msg = "ERROR: something unquoted happened";
88 let result = extract_quoted_identifier(msg, "table");
89 assert_eq!(result, msg);
90 }
91}