diff --git a/src/db/query.rs b/src/db/query.rs index 7ba01f9..21de18f 100644 --- a/src/db/query.rs +++ b/src/db/query.rs @@ -1,8 +1,281 @@ use anyhow::Result; use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; +use std::error::Error as StdError; +use std::fmt; use std::time::{Duration, Instant}; use tokio_postgres::{types::Type, Client, Row}; +/// Categorized error types for SQL query failures. +#[derive(Debug, Clone, PartialEq)] +pub enum ErrorCategory { + /// Syntax errors (SQLSTATE class 42 - syntax_error, etc.) + Syntax, + /// Semantic errors (missing table/column, ambiguous reference) + Semantic, + /// Execution/runtime errors (division by zero, constraint violation) + Execution, + /// Transaction state errors (e.g., transaction aborted) + Transaction, + /// Connection/communication errors + Connection, + /// Unknown or unclassified errors + Unknown, +} + +impl fmt::Display for ErrorCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ErrorCategory::Syntax => write!(f, "Syntax Error"), + ErrorCategory::Semantic => write!(f, "Semantic Error"), + ErrorCategory::Execution => write!(f, "Execution Error"), + ErrorCategory::Transaction => write!(f, "Transaction Error"), + ErrorCategory::Connection => write!(f, "Connection Error"), + ErrorCategory::Unknown => write!(f, "Error"), + } + } +} + +/// Structured error with rich context from PostgreSQL error responses. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct StructuredError { + /// Categorized error type + pub category: ErrorCategory, + /// PostgreSQL severity (ERROR, FATAL, etc.) + pub severity: String, + /// SQLSTATE error code (e.g., "42601" for syntax_error) + pub code: String, + /// Primary error message + pub message: String, + /// Optional detail providing more context + pub detail: Option, + /// Optional hint suggesting a fix + pub hint: Option, + /// Character position in the query where the error occurred (1-based byte offset) + pub position: Option, + /// Schema associated with the error + pub schema: Option, + /// Table associated with the error + pub table: Option, + /// Column associated with the error + pub column: Option, + /// Constraint associated with the error + pub constraint: Option, + /// Context/traceback (e.g., PL/pgSQL call stack) + pub where_: Option, + /// Computed line number (1-based) from position, if available + pub line: Option, + /// Computed column number (1-based) from position, if available + pub col: Option, +} + +#[allow(dead_code)] +impl StructuredError { + /// Create a StructuredError from a tokio_postgres error, using the query text + /// to compute line/column from the byte position. + pub fn from_pg_error(err: &tokio_postgres::Error, query: &str) -> Self { + if let Some(db_err) = err.as_db_error() { + let code_str = db_err.code().code().to_string(); + let category = categorize_sqlstate(&code_str); + let position = db_err.position().and_then(|p| match p { + tokio_postgres::error::ErrorPosition::Original(pos) => Some(*pos), + tokio_postgres::error::ErrorPosition::Internal { .. } => None, + }); + + let (line, col) = if let Some(pos) = position { + byte_offset_to_line_col(query, pos as usize) + } else { + (None, None) + }; + + StructuredError { + category, + severity: db_err.severity().to_string(), + code: code_str, + message: db_err.message().to_string(), + detail: db_err.detail().map(|s| s.to_string()), + hint: db_err.hint().map(|s| s.to_string()), + position, + schema: db_err.schema().map(|s| s.to_string()), + table: db_err.table().map(|s| s.to_string()), + column: db_err.column().map(|s| s.to_string()), + constraint: db_err.constraint().map(|s| s.to_string()), + where_: db_err.where_().map(|s| s.to_string()), + line, + col, + } + } else { + // Non-database error (connection, protocol, etc.) + let category = if err.source().is_some() { + ErrorCategory::Connection + } else { + ErrorCategory::Unknown + }; + StructuredError { + category, + severity: "ERROR".to_string(), + code: String::new(), + message: err.to_string(), + detail: err.source().map(|e| e.to_string()), + hint: None, + position: None, + schema: None, + table: None, + column: None, + constraint: None, + where_: None, + line: None, + col: None, + } + } + } + + /// Create a simple error from a plain string (for non-database errors). + pub fn from_string(msg: String) -> Self { + StructuredError { + category: ErrorCategory::Unknown, + severity: "ERROR".to_string(), + code: String::new(), + message: msg, + detail: None, + hint: None, + position: None, + schema: None, + table: None, + column: None, + constraint: None, + where_: None, + line: None, + col: None, + } + } + + /// Format as a single display string (for status bar, history, etc.) + pub fn display_message(&self) -> String { + self.message.clone() + } + + /// Format as a rich multi-line string for the results panel. + pub fn display_full(&self) -> String { + let mut lines = Vec::new(); + + // Category + message + lines.push(format!("{}: {}", self.category, self.message)); + + // Line/column + if let (Some(line), Some(col)) = (self.line, self.col) { + lines.push(format!(" at line {}, column {}", line, col)); + } + + // SQLSTATE code + if !self.code.is_empty() { + lines.push(format!(" SQLSTATE: {}", self.code)); + } + + // Detail + if let Some(detail) = &self.detail { + lines.push(format!(" Detail: {}", detail)); + } + + // Hint + if let Some(hint) = &self.hint { + lines.push(format!(" Hint: {}", hint)); + } + + // Schema/table/column context + if let Some(schema) = &self.schema { + if let Some(table) = &self.table { + if let Some(column) = &self.column { + lines.push(format!(" Object: {}.{}.{}", schema, table, column)); + } else { + lines.push(format!(" Object: {}.{}", schema, table)); + } + } + } else if let Some(table) = &self.table { + lines.push(format!(" Table: {}", table)); + } + + // Constraint + if let Some(constraint) = &self.constraint { + lines.push(format!(" Constraint: {}", constraint)); + } + + // Where context + if let Some(where_) = &self.where_ { + lines.push(format!(" Context: {}", where_)); + } + + lines.join("\n") + } +} + +impl fmt::Display for StructuredError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.display_message()) + } +} + +/// Convert a 1-based byte offset in a query string to (line, column) both 1-based. +fn byte_offset_to_line_col(query: &str, byte_pos: usize) -> (Option, Option) { + if byte_pos == 0 || query.is_empty() { + return (Some(1), Some(1)); + } + let target = (byte_pos - 1).min(query.len()); // PostgreSQL positions are 1-based + let mut line = 1usize; + let mut col = 1usize; + for (i, ch) in query.char_indices() { + if i >= target { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (Some(line), Some(col)) +} + +/// Categorize a SQLSTATE code into an ErrorCategory. +fn categorize_sqlstate(code: &str) -> ErrorCategory { + if code.len() < 2 { + return ErrorCategory::Unknown; + } + let class = &code[..2]; + match class { + // Class 42: Syntax Error or Access Rule Violation + "42" => { + // 42601 = syntax_error, 42501 = insufficient_privilege + if code == "42601" || code == "42000" { + ErrorCategory::Syntax + } else { + // 42P01 = undefined_table, 42703 = undefined_column, etc. + ErrorCategory::Semantic + } + } + // Class 22: Data Exception (division by zero, etc.) + "22" => ErrorCategory::Execution, + // Class 23: Integrity Constraint Violation + "23" => ErrorCategory::Execution, + // Class 25: Invalid Transaction State + "25" => ErrorCategory::Transaction, + // Class 40: Transaction Rollback + "40" => ErrorCategory::Transaction, + // Class 08: Connection Exception + "08" => ErrorCategory::Connection, + // Class 53: Insufficient Resources + "53" => ErrorCategory::Execution, + // Class 54: Program Limit Exceeded + "54" => ErrorCategory::Execution, + // Class 55: Object Not In Prerequisite State + "55" => ErrorCategory::Execution, + // Class 57: Operator Intervention + "57" => ErrorCategory::Execution, + _ => ErrorCategory::Unknown, + } +} + #[derive(Debug, Clone)] pub struct QueryResult { pub columns: Vec, @@ -10,7 +283,7 @@ pub struct QueryResult { pub row_count: usize, pub execution_time: Duration, pub affected_rows: Option, - pub error: Option, + pub error: Option, } #[derive(Debug, Clone)] @@ -85,14 +358,14 @@ impl QueryResult { } } - pub fn error(msg: String, execution_time: Duration) -> Self { + pub fn error(err: StructuredError, execution_time: Duration) -> Self { Self { columns: vec![], rows: vec![], row_count: 0, execution_time, affected_rows: None, - error: Some(msg), + error: Some(err), } } } @@ -118,7 +391,8 @@ pub async fn execute_query(client: &Client, sql: &str) -> Result { } Err(e) => { let execution_time = start.elapsed(); - Ok(QueryResult::error(e.to_string(), execution_time)) + let structured = StructuredError::from_pg_error(&e, sql_trimmed); + Ok(QueryResult::error(structured, execution_time)) } } } else { @@ -136,7 +410,8 @@ pub async fn execute_query(client: &Client, sql: &str) -> Result { } Err(e) => { let execution_time = start.elapsed(); - Ok(QueryResult::error(e.to_string(), execution_time)) + let structured = StructuredError::from_pg_error(&e, sql_trimmed); + Ok(QueryResult::error(structured, execution_time)) } } } @@ -287,11 +562,82 @@ mod tests { #[test] fn test_error_result() { - let r = QueryResult::error("bad query".into(), Duration::from_millis(10)); + let r = QueryResult::error( + StructuredError::from_string("bad query".into()), + Duration::from_millis(10), + ); assert!(r.error.is_some()); - assert_eq!(r.error.unwrap(), "bad query"); + assert_eq!(r.error.as_ref().unwrap().message, "bad query"); assert!(r.rows.is_empty()); } + + #[test] + fn test_structured_error_category_display() { + assert_eq!(ErrorCategory::Syntax.to_string(), "Syntax Error"); + assert_eq!(ErrorCategory::Semantic.to_string(), "Semantic Error"); + assert_eq!(ErrorCategory::Execution.to_string(), "Execution Error"); + assert_eq!(ErrorCategory::Transaction.to_string(), "Transaction Error"); + assert_eq!(ErrorCategory::Connection.to_string(), "Connection Error"); + assert_eq!(ErrorCategory::Unknown.to_string(), "Error"); + } + + #[test] + fn test_structured_error_from_string() { + let err = StructuredError::from_string("test error".into()); + assert_eq!(err.category, ErrorCategory::Unknown); + assert_eq!(err.message, "test error"); + assert!(err.detail.is_none()); + assert!(err.hint.is_none()); + assert!(err.position.is_none()); + } + + #[test] + fn test_structured_error_display_full() { + let err = StructuredError { + category: ErrorCategory::Syntax, + severity: "ERROR".to_string(), + code: "42601".to_string(), + message: "syntax error at or near \",\"".to_string(), + detail: None, + hint: Some("Remove trailing comma.".to_string()), + position: Some(45), + schema: None, + table: None, + column: None, + constraint: None, + where_: None, + line: Some(3), + col: Some(1), + }; + let full = err.display_full(); + assert!(full.contains("Syntax Error")); + assert!(full.contains("at line 3, column 1")); + assert!(full.contains("42601")); + assert!(full.contains("Remove trailing comma")); + } + + #[test] + fn test_byte_offset_to_line_col() { + let query = "SELECT *\nFROM users\nWHERE id = 1"; + // Position 1 = 'S' on line 1, col 1 + assert_eq!(byte_offset_to_line_col(query, 1), (Some(1), Some(1))); + // Position 10 = 'F' on line 2, col 1 + assert_eq!(byte_offset_to_line_col(query, 10), (Some(2), Some(1))); + // Position 21 = 'W' on line 3, col 1 + assert_eq!(byte_offset_to_line_col(query, 21), (Some(3), Some(1))); + } + + #[test] + fn test_categorize_sqlstate() { + assert_eq!(categorize_sqlstate("42601"), ErrorCategory::Syntax); + assert_eq!(categorize_sqlstate("42P01"), ErrorCategory::Semantic); + assert_eq!(categorize_sqlstate("42703"), ErrorCategory::Semantic); + assert_eq!(categorize_sqlstate("23505"), ErrorCategory::Execution); + assert_eq!(categorize_sqlstate("22012"), ErrorCategory::Execution); + assert_eq!(categorize_sqlstate("25001"), ErrorCategory::Transaction); + assert_eq!(categorize_sqlstate("08006"), ErrorCategory::Connection); + assert_eq!(categorize_sqlstate("XX000"), ErrorCategory::Unknown); + } } fn extract_value(row: &Row, idx: usize, pg_type: &Type) -> CellValue { diff --git a/src/ui/app.rs b/src/ui/app.rs index cd3e4e5..cec50b6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1651,7 +1651,10 @@ impl App { // Update status if let Some(err) = &result.error { - self.set_status(format!("Error: {}", err), StatusType::Error); + self.set_status( + format!("{}: {}", err.category, err.message), + StatusType::Error, + ); } else if let Some(affected) = result.affected_rows { self.set_status( format!( diff --git a/src/ui/components.rs b/src/ui/components.rs index fdae4d5..88c3fe0 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -636,10 +636,10 @@ fn draw_results(frame: &mut Frame, app: &App, area: Rect) { } else { String::new() }; - if result.error.is_some() { + if let Some(err) = &result.error { format!( - " Results ({}/{}) - ERROR ({:.2}ms) ", - result_index, result_total, time_ms + " Results ({}/{}) - {} ({:.2}ms) ", + result_index, result_total, err.category, time_ms ) } else if let Some(affected) = result.affected_rows { format!( @@ -688,10 +688,7 @@ fn draw_results(frame: &mut Frame, app: &App, area: Rect) { } } else if let Some(result) = app.results.get(app.current_result) { if let Some(error) = &result.error { - let error_text = Paragraph::new(error.as_str()) - .style(theme.status_error()) - .wrap(Wrap { trim: true }); - frame.render_widget(error_text, inner); + draw_structured_error(frame, app, error, inner); } else if result.columns.is_empty() { if let Some(affected) = result.affected_rows { let msg = format!("{} rows affected", affected); @@ -791,6 +788,139 @@ fn draw_result_table(frame: &mut Frame, app: &App, result: &crate::db::QueryResu frame.render_widget(table, area); } +fn draw_structured_error( + frame: &mut Frame, + app: &App, + error: &crate::db::StructuredError, + area: Rect, +) { + let theme = &app.theme; + let mut lines: Vec = Vec::new(); + + // Category + severity header + let category_color = match error.category { + crate::db::ErrorCategory::Syntax => theme.error, + crate::db::ErrorCategory::Semantic => theme.warning, + crate::db::ErrorCategory::Execution => theme.error, + crate::db::ErrorCategory::Transaction => theme.warning, + crate::db::ErrorCategory::Connection => theme.error, + crate::db::ErrorCategory::Unknown => theme.error, + }; + + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", error.category), + Style::default() + .fg(category_color) + .add_modifier(Modifier::BOLD), + ), + if !error.code.is_empty() { + Span::styled( + format!("[{}] ", error.code), + Style::default().fg(theme.text_muted), + ) + } else { + Span::raw("") + }, + ])); + + // Main error message + lines.push(Line::from(Span::styled( + format!(" {}", error.message), + Style::default().fg(theme.text_primary), + ))); + + // Line/column info + if let (Some(line), Some(col)) = (error.line, error.col) { + lines.push(Line::from(Span::styled( + format!(" at line {}, column {}", line, col), + Style::default().fg(theme.text_accent), + ))); + } + + lines.push(Line::from("")); + + // Detail + if let Some(detail) = &error.detail { + lines.push(Line::from(vec![ + Span::styled( + " Detail: ", + Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(detail.clone(), Style::default().fg(theme.text_primary)), + ])); + } + + // Hint + if let Some(hint) = &error.hint { + lines.push(Line::from(vec![ + Span::styled( + " Hint: ", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + ), + Span::styled(hint.clone(), Style::default().fg(theme.text_primary)), + ])); + } + + // Schema/table/column context + if error.table.is_some() || error.schema.is_some() || error.column.is_some() { + let mut parts = Vec::new(); + if let Some(schema) = &error.schema { + parts.push(schema.clone()); + } + if let Some(table) = &error.table { + parts.push(table.clone()); + } + if let Some(column) = &error.column { + parts.push(column.clone()); + } + lines.push(Line::from(vec![ + Span::styled( + " Object: ", + Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(parts.join("."), Style::default().fg(theme.text_primary)), + ])); + } + + // Constraint + if let Some(constraint) = &error.constraint { + lines.push(Line::from(vec![ + Span::styled( + " Constraint: ", + Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(constraint.clone(), Style::default().fg(theme.text_primary)), + ])); + } + + // Where/context (e.g., PL/pgSQL stack trace) + if let Some(where_) = &error.where_ { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Context:", + Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD), + ))); + for ctx_line in where_.lines() { + lines.push(Line::from(Span::styled( + format!(" {}", ctx_line), + Style::default().fg(theme.text_muted), + ))); + } + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + fn draw_explain_plan(frame: &mut Frame, app: &App, plan: &QueryPlan, area: Rect) { let theme = &app.theme; let mut lines: Vec = Vec::new();