//! Driver-reported SQL error positions (currently PostgreSQL only). //! //! PostgreSQL reports a 1-based *character* cursor position for many SQL //! errors (`tokio_postgres::error::ErrorPosition::Original`). The native //! driver error path in `db::postgres` only exposes a `String` to the query //! layer, so the raw cursor is carried across that `String` ABI as a small //! machine-readable suffix and resolved back into a typed [`SqlErrorPosition`] //! as soon as the executed statement text is available. //! //! The suffix is a position carrier, not an error classifier: nothing about the //! error category, retryability or recovery is inferred from it, and //! [`strip_marker`] guarantees it can never surface in a user-facing message. use serde::{Deserialize, Serialize}; /// Suffix appended to a driver error message to carry a raw PostgreSQL cursor /// position across the `Result<_, String>` boundary of the `db` layer. pub const SQL_ERROR_POSITION_MARKER: &str = "\nDBX_SQL_ERROR_POSITION:"; /// A SQL error position relative to the statement text that was actually sent /// to the database. /// /// `line`/`column` are 1-based and counted in Unicode scalar values (matching /// PostgreSQL's character-based cursor positions); `offset` is the 0-based /// scalar-value index into the statement text. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlErrorPosition { pub line: u32, pub column: u32, pub offset: u32, } impl SqlErrorPosition { /// Convert a PostgreSQL 1-based character cursor into a line/column/offset. /// /// Returns `None` when the cursor is unusable (zero, non-positive, or the /// statement is empty). A cursor past the end (`position == len + 1`, e.g. /// "unexpected end of input") is clamped to the last character. pub fn from_pg_cursor(statement_sql: &str, cursor: u32) -> Option { if cursor == 0 { return None; } let total = statement_sql.chars().count() as u32; if total == 0 { return None; } let target = (cursor - 1).min(total - 1); let mut line = 1u32; let mut column = 1u32; for (index, ch) in statement_sql.chars().enumerate() { if index as u32 == target { return Some(Self { line, column, offset: target }); } if ch == '\n' { line += 1; column = 1; } else { column += 1; } } None } } /// Encode a raw cursor as the transport suffix. pub fn encode_marker(cursor: u32) -> String { format!("{SQL_ERROR_POSITION_MARKER}{cursor}") } /// Remove every marker from `message` and return the cursors they carried. /// /// Markers are removed even when followed by more text (the read-only /// transaction path can append a cleanup error after one, e.g. /// `...15; rollback failed`), so the trailing text is preserved. A /// malformed or absent suffix leaves `message` untouched and is not collected. fn take_all_markers(message: &mut String) -> Vec { let mut cursors = Vec::new(); while let Some(cursor) = take_marker(message) { cursors.push(cursor); } cursors } /// Remove a marker from `message` and return the raw cursor it carried. /// /// See [`take_all_markers`] for the multi-marker behaviour. pub fn take_marker(message: &mut String) -> Option { let index = message.rfind(SQL_ERROR_POSITION_MARKER)?; let digits_start = index + SQL_ERROR_POSITION_MARKER.len(); let digits_end = message[digits_start..] .find(|ch: char| !ch.is_ascii_digit()) .map_or(message.len(), |offset| digits_start + offset); let cursor = message[digits_start..digits_end].parse::().ok()?; message.replace_range(index..digits_end, ""); Some(cursor) } /// Return `message` with every transport suffix stripped. pub fn strip_marker(message: &str) -> String { let mut owned = message.to_string(); let _ = take_all_markers(&mut owned); owned } /// Strip all transport suffixes from `message` and, when possible, resolve the /// carried cursor against the executed statement text into a typed position. /// /// The returned message is always marker-free. The position is only resolved /// when exactly one marker was present: a message carrying several markers (e.g. /// a user-statement error merged with an infrastructure error) cannot be /// attributed to a single statement, and guessing would point at the wrong spot. pub fn take_message_position(message: &str, statement_sql: &str) -> (String, Option) { let mut cleaned = message.to_string(); let cursors = take_all_markers(&mut cleaned); if cursors.len() != 1 { return (cleaned, None); } let position = SqlErrorPosition::from_pg_cursor(statement_sql, cursors[0]); (cleaned, position) } #[cfg(test)] mod tests { use super::*; #[test] fn cursor_maps_to_line_and_column() { let sql = "SELECT *\nFROM no_such_table"; // 1-based cursor of the 'n' in no_such_table (second line, column 6). let cursor = sql.find("no_such_table").unwrap() as u32 + 1; let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap(); assert_eq!(position.line, 2); assert_eq!(position.column, 6); assert_eq!(position.offset, sql[..sql.find("no_such_table").unwrap()].chars().count() as u32); } #[test] fn first_character_is_line_one_column_one() { let position = SqlErrorPosition::from_pg_cursor("SELECT 1", 1).unwrap(); assert_eq!((position.line, position.column, position.offset), (1, 1, 0)); } #[test] fn crlf_treats_carriage_return_as_a_column() { let sql = "SELECT 1\r\nFROM t"; let cursor = sql.find("FROM").unwrap() as u32 + 1; let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap(); assert_eq!(position.line, 2); assert_eq!(position.column, 1); } #[test] fn multi_byte_characters_count_as_one_column() { // '表' is 3 UTF-8 bytes; cursor positions are character based. let sql = "SELECT '表' FROM t"; let from_byte = sql.find("FROM").unwrap(); let cursor = sql[..from_byte].chars().count() as u32 + 1; let position = SqlErrorPosition::from_pg_cursor(sql, cursor).unwrap(); assert_eq!(position.line, 1); assert_eq!(position.column, 12); assert_eq!(position.offset, 11); } #[test] fn cursor_past_end_is_clamped() { let sql = "SELECT"; let position = SqlErrorPosition::from_pg_cursor(sql, 999).unwrap(); assert_eq!(position.offset, 5); assert_eq!(position.column, 6); } #[test] fn zero_cursor_and_empty_sql_are_rejected() { assert!(SqlErrorPosition::from_pg_cursor("SELECT 1", 0).is_none()); assert!(SqlErrorPosition::from_pg_cursor("", 1).is_none()); } #[test] fn marker_round_trips_and_strips() { let mut message = format!("ERROR: relation does not exist{}", encode_marker(15)); assert_eq!(take_marker(&mut message), Some(15)); assert_eq!(message, "ERROR: relation does not exist"); let mut without_marker = "ERROR: nope".to_string(); assert_eq!(take_marker(&mut without_marker), None); assert_eq!(without_marker, "ERROR: nope"); } #[test] fn malformed_marker_is_left_untouched() { let text = format!("{SQL_ERROR_POSITION_MARKER}not-a-number"); let mut message = text.clone(); assert_eq!(take_marker(&mut message), None); assert_eq!(message, text); } #[test] fn marker_followed_by_text_is_stripped_and_preserves_the_tail() { let mut message = format!("ERROR: boom{}; rollback failed", encode_marker(7)); assert_eq!(take_marker(&mut message), Some(7)); assert_eq!(message, "ERROR: boom; rollback failed"); } #[test] fn take_message_position_strips_marker_and_resolves_position() { let sql = "SELECT 1\nFROM missing"; let cursor = sql.find("missing").unwrap() as u32 + 1; let raw = format!("ERROR: relation \"missing\" does not exist{}", encode_marker(cursor)); let (message, position) = take_message_position(&raw, sql); assert_eq!(message, "ERROR: relation \"missing\" does not exist"); let position = position.unwrap(); assert_eq!(position.line, 2); assert_eq!(position.column, 6); } #[test] fn take_message_position_still_strips_when_the_cursor_is_unusable() { // cursor 0 cannot be resolved, but the marker must not leak. let raw = format!("ERROR: boom{}", encode_marker(0)); let (message, position) = take_message_position(&raw, "SELECT 1"); assert_eq!(message, "ERROR: boom"); assert!(position.is_none()); // Empty statement: nothing to map onto. let raw = format!("ERROR: boom{}", encode_marker(5)); let (message, position) = take_message_position(&raw, ""); assert_eq!(message, "ERROR: boom"); assert!(position.is_none()); } #[test] fn take_message_position_without_marker_is_identity() { let (message, position) = take_message_position("ERROR: x", "SELECT 1"); assert_eq!(message, "ERROR: x"); assert!(position.is_none()); } #[test] fn multiple_markers_are_all_stripped_and_never_resolved() { // A user-statement error merged with an infrastructure error carries two // cursors; the position cannot be attributed to a single statement. let raw = format!("ERROR: relation missing does not exist{}; cleanup failed{}", encode_marker(15), encode_marker(3)); let (message, position) = take_message_position(&raw, "SELECT * FROM missing"); assert_eq!(message, "ERROR: relation missing does not exist; cleanup failed"); assert!(position.is_none()); assert_eq!(strip_marker(&raw), "ERROR: relation missing does not exist; cleanup failed"); } #[test] fn strip_marker_without_marker_is_identity() { assert_eq!(strip_marker("ERROR: x"), "ERROR: x"); } }