bestool_postgres/
error.rs

1use miette::{Diagnostic, LabeledSpan, NamedSource, SourceCode};
2use tokio_postgres::error::{DbError, Error};
3
4use crate::pool::PgError;
5
6/// Custom error type for formatting PostgreSQL database errors as miette diagnostics
7#[derive(Debug, thiserror::Error)]
8pub struct PgDatabaseError {
9	message: String,
10	hint: Option<String>,
11	source_code: Option<NamedSource<String>>,
12	label: Option<miette::SourceSpan>,
13	label_text: String,
14	severity: String,
15	code: String,
16	detail: Option<String>,
17	where_clause: Option<String>,
18	schema: Option<String>,
19	table: Option<String>,
20	column: Option<String>,
21	datatype: Option<String>,
22	constraint: Option<String>,
23	file: Option<String>,
24	line: Option<u32>,
25	routine: Option<String>,
26}
27
28impl std::fmt::Display for PgDatabaseError {
29	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30		writeln!(f, "{}: {}", self.severity, self.message)?;
31		writeln!(f, "  Code: {}", self.code)?;
32
33		if let Some(detail) = &self.detail {
34			writeln!(f, "  Detail: {}", detail)?;
35		}
36		if let Some(where_clause) = &self.where_clause {
37			writeln!(f, "  Where: {}", where_clause)?;
38		}
39		if let Some(schema) = &self.schema {
40			writeln!(f, "  Schema: {}", schema)?;
41		}
42		if let Some(table) = &self.table {
43			writeln!(f, "  Table: {}", table)?;
44		}
45		if let Some(column) = &self.column {
46			writeln!(f, "  Column: {}", column)?;
47		}
48		if let Some(datatype) = &self.datatype {
49			writeln!(f, "  Datatype: {}", datatype)?;
50		}
51		if let Some(constraint) = &self.constraint {
52			writeln!(f, "  Constraint: {}", constraint)?;
53		}
54		if let Some(hint) = &self.hint {
55			writeln!(f, "  Hint: {}", hint)?;
56		}
57		if let Some(file) = &self.file {
58			write!(f, "  Source: {}", file)?;
59			if let Some(line) = self.line {
60				write!(f, ":{}", line)?;
61			}
62			if let Some(routine) = &self.routine {
63				write!(f, " in {}", routine)?;
64			}
65			writeln!(f)?;
66		}
67
68		Ok(())
69	}
70}
71
72impl Diagnostic for PgDatabaseError {
73	fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
74		self.hint
75			.as_ref()
76			.map(|h| Box::new(h.clone()) as Box<dyn std::fmt::Display>)
77	}
78
79	fn source_code(&self) -> Option<&dyn SourceCode> {
80		self.source_code.as_ref().map(|s| s as &dyn SourceCode)
81	}
82
83	fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
84		if let Some(span) = self.label {
85			Some(Box::new(std::iter::once(LabeledSpan::new_with_span(
86				Some(self.label_text.clone()),
87				span,
88			))))
89		} else {
90			None
91		}
92	}
93}
94
95impl PgDatabaseError {
96	/// Create a PgDatabaseError from a tokio-postgres DbError
97	pub fn from_db_error(db_error: &DbError, query: Option<&str>) -> Self {
98		let message = db_error.message().to_string();
99		let hint = db_error.hint().map(|s| s.to_string());
100		let detail = db_error.detail().map(|s| s.to_string());
101		let code = db_error.code().code().to_string();
102		let severity = db_error
103			.parsed_severity()
104			.map(|s| format!("{:?}", s))
105			.unwrap_or_else(|| db_error.severity().to_string());
106		let position = db_error.position().map(|pos| match pos {
107			tokio_postgres::error::ErrorPosition::Original(p) => format!("position {}", p),
108			tokio_postgres::error::ErrorPosition::Internal { position, query } => {
109				format!("internal position {} in query: {}", position, query)
110			}
111		});
112		let where_clause = db_error.where_().map(|s| s.to_string());
113		let schema = db_error.schema().map(|s| s.to_string());
114		let table = db_error.table().map(|s| s.to_string());
115		let column = db_error.column().map(|s| s.to_string());
116		let datatype = db_error.datatype().map(|s| s.to_string());
117		let constraint = db_error.constraint().map(|s| s.to_string());
118		let file = db_error.file().map(|s| s.to_string());
119		let line = db_error.line();
120		let routine = db_error.routine().map(|s| s.to_string());
121
122		// Create source code and label if we have both query and position
123		if let (Some(query_str), Some(pos_str)) = (query, &position)
124			&& let Some(pos_str) = pos_str.strip_prefix("position ")
125			&& let Ok(pos) = pos_str.parse::<usize>()
126		{
127			// PostgreSQL positions are 1-based, convert to 0-based
128			let pos_zero_based = pos.saturating_sub(1);
129
130			// Ensure position is within query bounds
131			let actual_pos = pos_zero_based.min(query_str.len().saturating_sub(1));
132
133			let source = NamedSource::new("query", query_str.to_string());
134			let span = miette::SourceSpan::from(actual_pos..actual_pos + 1);
135			return Self {
136				message,
137				hint,
138				source_code: Some(source),
139				label: Some(span),
140				label_text: "error here".to_string(),
141				severity,
142				code,
143				detail,
144				where_clause,
145				schema,
146				table,
147				column,
148				datatype,
149				constraint,
150				file,
151				line,
152				routine,
153			};
154		}
155
156		Self {
157			message,
158			hint,
159			source_code: None,
160			label: None,
161			label_text: String::new(),
162			severity,
163			code,
164			detail,
165			where_clause,
166			schema,
167			table,
168			column,
169			datatype,
170			constraint,
171			file,
172			line,
173			routine,
174		}
175	}
176}
177
178/// Format a `mobc::Error<PgError>` Error for display
179pub fn format_mobc_error(error: &mobc::Error<PgError>, query: Option<&str>) -> String {
180	match error {
181		mobc::Error::Inner(PgError::Pg(error)) => format_db_error(error, query),
182		error => format!("{:?}", error),
183	}
184}
185
186/// Format a tokio-postgres Error for display
187pub fn format_db_error(error: &Error, query: Option<&str>) -> String {
188	match error.as_db_error() {
189		Some(db_error) => {
190			let pg_error = PgDatabaseError::from_db_error(db_error, query);
191
192			// If we have source code, use miette's fancy formatting
193			if pg_error.source_code.is_some() {
194				format!("{:?}", miette::Report::new(pg_error))
195			} else {
196				// Otherwise use our custom Display implementation
197				format!("{}", pg_error)
198			}
199		}
200		None => format!("{:?}", error),
201	}
202}
203
204/// Format any error for display
205pub fn format_error(error: &dyn std::fmt::Debug) -> String {
206	format!("{:?}", error)
207}
208
209/// Format a miette Report for display, extracting database errors if present
210pub fn format_miette_error(report: &miette::Report, query: Option<&str>) -> String {
211	// Try to downcast to PgDatabaseError first (already has query and all details)
212	if let Some(pg_db_error) = report.downcast_ref::<PgDatabaseError>() {
213		// If we have source code, use miette's fancy formatting
214		if pg_db_error.source_code.is_some() {
215			return format!("{:?}", report);
216		} else {
217			// Otherwise use our custom Display implementation
218			return format!("{}", pg_db_error);
219		}
220	}
221
222	// Try to downcast to tokio_postgres::Error
223	if let Some(pg_error) = report.downcast_ref::<Error>() {
224		return format_db_error(pg_error, query);
225	}
226
227	// Otherwise, format the report
228	// Use Debug formatting which will use miette's fancy output if available
229	format!("{:?}", report)
230}
231
232#[cfg(test)]
233mod tests {
234	use super::*;
235
236	#[tokio::test]
237	async fn test_format_db_error_with_real_error() {
238		// This test requires DATABASE_URL to be set
239		let database_url = std::env::var("DATABASE_URL")
240			.unwrap_or_else(|_| "postgresql://localhost/postgres".to_string());
241
242		let (client, connection) = tokio_postgres::connect(&database_url, tokio_postgres::NoTls)
243			.await
244			.expect("Failed to connect to database");
245
246		tokio::spawn(async move {
247			if let Err(e) = connection.await {
248				eprintln!("Connection error: {}", e);
249			}
250		});
251
252		// Execute a query that will definitely fail with a syntax error
253		let result = client.query("SELECT 1+;", &[]).await;
254
255		assert!(result.is_err(), "Query should have failed");
256
257		let error = result.unwrap_err();
258		let formatted = format_db_error(&error, Some("SELECT 1+;"));
259
260		// Check that the formatted error contains expected information
261		assert!(formatted.contains("ERROR") || formatted.contains("syntax error"));
262		assert!(formatted.contains("Code:"));
263
264		println!("Formatted error:\n{}", formatted);
265	}
266
267	#[test]
268	fn test_pg_database_error_display() {
269		let error = PgDatabaseError {
270			message: "syntax error at end of input".to_string(),
271			hint: Some("Check your SQL syntax".to_string()),
272			source_code: None,
273			label: None,
274			label_text: String::new(),
275			severity: "ERROR".to_string(),
276			code: "42601".to_string(),
277			detail: Some("The query ended unexpectedly".to_string()),
278			where_clause: None,
279			schema: None,
280			table: Some("users".to_string()),
281			column: None,
282			datatype: None,
283			constraint: None,
284			file: Some("parser.c".to_string()),
285			line: Some(123),
286			routine: Some("parse_query".to_string()),
287		};
288
289		let output = format!("{}", error);
290		assert!(output.contains("ERROR: syntax error at end of input"));
291		assert!(output.contains("Code: 42601"));
292		assert!(output.contains("Detail: The query ended unexpectedly"));
293		assert!(output.contains("Table: users"));
294		assert!(output.contains("Hint: Check your SQL syntax"));
295		assert!(output.contains("Source: parser.c:123 in parse_query"));
296	}
297
298	#[test]
299	fn test_pg_database_error_minimal() {
300		let error = PgDatabaseError {
301			message: "column does not exist".to_string(),
302			hint: None,
303			source_code: None,
304			label: None,
305			label_text: String::new(),
306			severity: "ERROR".to_string(),
307			code: "42703".to_string(),
308			detail: None,
309			where_clause: None,
310			schema: None,
311			table: None,
312			column: None,
313			datatype: None,
314			constraint: None,
315			file: None,
316			line: None,
317			routine: None,
318		};
319
320		let output = format!("{}", error);
321		assert!(output.contains("ERROR: column does not exist"));
322		assert!(output.contains("Code: 42703"));
323		// Should not contain optional fields
324		assert!(!output.contains("Detail:"));
325		assert!(!output.contains("Hint:"));
326		assert!(!output.contains("Source:"));
327	}
328
329	#[tokio::test]
330	async fn test_complete_error_output() {
331		// This test demonstrates the complete error output with all fields
332		let database_url = std::env::var("DATABASE_URL")
333			.unwrap_or_else(|_| "postgresql://localhost/postgres".to_string());
334
335		let (client, connection) = tokio_postgres::connect(&database_url, tokio_postgres::NoTls)
336			.await
337			.expect("Failed to connect to database");
338
339		tokio::spawn(async move {
340			if let Err(e) = connection.await {
341				eprintln!("Connection error: {}", e);
342			}
343		});
344
345		// Test 1: Syntax error
346		let result = client.query("SELECT 1+;", &[]).await;
347		if let Err(e) = result
348			&& let Some(db_error) = e.as_db_error()
349		{
350			let pg_error = PgDatabaseError::from_db_error(db_error, Some("SELECT 1+;"));
351			let output = format!("{}", pg_error);
352			println!("=== Syntax Error Output ===\n{}", output);
353			assert!(output.contains("Code:"));
354			assert!(output.contains("Position:") || output.contains("Source:"));
355		}
356
357		// Test 2: Column does not exist
358		let result = client.query("SELECT nonexistent FROM pg_class", &[]).await;
359		if let Err(e) = result
360			&& let Some(db_error) = e.as_db_error()
361		{
362			let pg_error =
363				PgDatabaseError::from_db_error(db_error, Some("SELECT nonexistent FROM pg_class"));
364			let output = format!("{}", pg_error);
365			println!("=== Column Error Output ===\n{}", output);
366			assert!(output.contains("Code:"));
367		}
368	}
369
370	#[test]
371	fn test_miette_span_rendering() {
372		// Test to verify miette renders the span at the correct position
373		let query = "SELECT 1+;";
374
375		// Test 1: Hardcoded span at bytes 3-5 (should highlight "EC")
376		let error = PgDatabaseError {
377			message: "syntax error".to_string(),
378			hint: None,
379			source_code: Some(NamedSource::new("query", query.to_string())),
380			label: Some(miette::SourceSpan::from(3..5)),
381			label_text: "at bytes 3-5".to_string(),
382			severity: "ERROR".to_string(),
383			code: "42601".to_string(),
384			detail: None,
385			where_clause: None,
386			schema: None,
387			table: None,
388			column: None,
389			datatype: None,
390			constraint: None,
391			file: None,
392			line: None,
393			routine: None,
394		};
395
396		let report = miette::Report::new(error);
397		let formatted = format!("{:?}", report);
398		println!("Test 1 - Span at bytes 3-5:\n{}", formatted);
399
400		// Test 2: Span at the semicolon (byte 9)
401		let error2 = PgDatabaseError {
402			message: "syntax error".to_string(),
403			hint: None,
404			source_code: Some(NamedSource::new("query", query.to_string())),
405			label: Some(miette::SourceSpan::from(9..10)),
406			label_text: "semicolon".to_string(),
407			severity: "ERROR".to_string(),
408			code: "42601".to_string(),
409			detail: None,
410			where_clause: None,
411			schema: None,
412			table: None,
413			column: None,
414			datatype: None,
415			constraint: None,
416			file: None,
417			line: None,
418			routine: None,
419		};
420
421		let report2 = miette::Report::new(error2);
422		let formatted2 = format!("{:?}", report2);
423		println!("Test 2 - Span at byte 9 (semicolon):\n{}", formatted2);
424
425		// Verify the query is in the output
426		assert!(formatted.contains("SELECT"));
427		assert!(formatted2.contains("SELECT"));
428	}
429}