Skip to main content

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}