Skip to content

Commit 3df64c9

Browse files
committed
breaking(postgres): add position information to errors
Breaking change: renamed `PgDatabaseError::position()` to `::pg_error_position()` to not conflict with `DatabaseError::position()`
1 parent 23a1f41 commit 3df64c9

File tree

6 files changed

+277
-84
lines changed

6 files changed

+277
-84
lines changed

sqlx-core/src/error.rs

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,22 @@ pub trait DatabaseError: 'static + Send + Sync + StdError {
193193
None
194194
}
195195

196-
/// The position in the query where the error occurred, if applicable.
196+
/// The line and column in the executed SQL where the error occurred,
197+
/// if applicable and supported by the database.
197198
///
198199
/// ### Note
199-
/// This assumes that Rust and the database server agree on the definition of "character",
200-
/// i.e. a Unicode scalar value.
200+
/// This may return an incorrect result if the database server disagrees with Rust
201+
/// on the definition of a "character", i.e. a Unicode scalar value. This position should not
202+
/// be considered authoritative.
203+
///
204+
/// This also may not be returned or made readily available by every database flavor.
205+
///
206+
/// For example, MySQL and MariaDB do not include the error position as a specific field
207+
/// in the `ERR_PACKET` structure; the line number that appears in the error message is part
208+
/// of the message string generated by the database server.
209+
///
210+
/// SQLx does not attempt to parse the line number from the message string,
211+
/// as we cannot assume that the exact message format is a stable part of the API contract.
201212
fn position(&self) -> Option<ErrorPosition> {
202213
None
203214
}
@@ -330,40 +341,53 @@ macro_rules! err_protocol {
330341
};
331342
}
332343

333-
/// The line and column (1-based) in the query where the server says an error occurred.
344+
/// Details the position in an SQL string where the server says an error occurred.
334345
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
335346
pub struct ErrorPosition {
336-
/// The line number (1-based) in the query where the server says the error occurred.
347+
/// The byte offset where the error occurred.
348+
pub byte_offset: usize,
349+
/// The character (Unicode scalar value) offset where the error occurred.
350+
pub char_offset: usize,
351+
/// The line number (1-based) in the string.
337352
pub line: usize,
338-
/// The column (1-based) in the query where the server says the error occurred.
353+
/// The column position (1-based) in the string.
339354
pub column: usize,
340355
}
341356

342357
/// The character basis for an error position. Used with [`ErrorPosition`].
343-
pub enum CharBasis {
358+
#[derive(Debug)]
359+
pub enum PositionBasis {
360+
/// A zero-based byte offset.
361+
ByteOffset(usize),
344362
/// A zero-based character index.
345-
Zero(usize),
363+
CharIndex(usize),
346364
/// A 1-based character position.
347-
One(usize)
365+
CharPos(usize),
348366
}
349367

350368
impl ErrorPosition {
351-
/// Given a query string and a character position, return the line and column in the query.
369+
/// Given a query string and a character basis (byte offset, 0-based index or 1-based position),
370+
/// return the line and column.
371+
///
372+
/// Returns `None` if the character basis is out-of-bounds,
373+
/// does not lie on a character boundary (byte offsets only),
374+
/// or overflows `usize`.
352375
///
353376
/// ### Note
354377
/// This assumes that Rust and the database server agree on the definition of "character",
355378
/// i.e. a Unicode scalar value.
356-
pub fn from_char_pos(query: &str, char_basis: CharBasis) -> Option<ErrorPosition> {
357-
// UTF-8 encoding forces us to count characters from the beginning.
358-
let char_idx = char_basis.to_index()?;
379+
pub fn find(query: &str, basis: PositionBasis) -> Option<ErrorPosition> {
380+
let mut pos = ErrorPosition { byte_offset: 0, char_offset: 0, line: 1, column: 1 };
359381

360-
let mut pos = ErrorPosition {
361-
line: 1,
362-
column: 1,
363-
};
382+
for (char_idx, (byte_idx, ch)) in query.char_indices().enumerate() {
383+
pos.byte_offset = byte_idx;
384+
pos.char_offset = char_idx;
364385

365-
for (i, ch) in query.chars().enumerate() {
366-
if i == char_idx { return Some(pos); }
386+
// Note: since line and column are 1-based,
387+
// we technically don't want to advance until the top of the next loop.
388+
if pos.basis_reached(&basis) {
389+
return Some(pos);
390+
}
367391

368392
if ch == '\n' {
369393
pos.line = pos.line.checked_add(1)?;
@@ -373,43 +397,74 @@ impl ErrorPosition {
373397
}
374398
}
375399

376-
None
400+
// Check if the end of the string matches our basis.
401+
pos.byte_offset = query.len();
402+
pos.char_offset = pos.char_offset.checked_add(1)?;
403+
404+
pos.basis_reached(&basis).then_some(pos)
377405
}
378-
}
379406

380-
impl CharBasis {
381-
fn to_index(&self) -> Option<usize> {
382-
match *self {
383-
CharBasis::Zero(idx) => Some(idx),
384-
CharBasis::One(pos) => pos.checked_sub(1),
407+
fn basis_reached(&self, basis: &PositionBasis) -> bool {
408+
match *basis {
409+
PositionBasis::ByteOffset(offset) => {
410+
self.byte_offset == offset
411+
}
412+
PositionBasis::CharIndex(char_idx) => {
413+
self.char_offset == char_idx
414+
}
415+
PositionBasis::CharPos(char_pos) => {
416+
self.char_offset.checked_add(1) == Some(char_pos)
417+
}
385418
}
386419
}
387420
}
388421

389422
#[test]
390423
fn test_error_position() {
391-
macro_rules! test_error_position {
392-
// Note: only tests one-based positions since zero-based is most of the same steps.
393-
($query:expr, pos: $pos:expr, line: $line:expr, col: $column:expr; $($tt:tt)*) => {
394-
let expected = ErrorPosition { line: $line, column: $column };
395-
let actual = ErrorPosition::from_char_pos($query, CharBasis::One($pos));
396-
assert_eq!(actual, Some(expected), "for position {} in query {:?}", $pos, $query);
397-
398-
test_error_position!($($tt)*);
399-
};
400-
($query:expr, pos: $pos:expr, None; $($tt:tt)*) => {
401-
let actual = ErrorPosition::from_char_pos($query, CharBasis::One($pos));
402-
assert_eq!(actual, None, "for position {} in query {:?}", $pos, $query);
403-
404-
test_error_position!($($tt)*);
405-
};
406-
() => {}
407-
}
424+
assert_eq!(
425+
ErrorPosition::find(
426+
"SELECT foo",
427+
PositionBasis::CharPos(8),
428+
),
429+
Some(ErrorPosition {
430+
byte_offset: 7,
431+
char_offset: 7,
432+
line: 1,
433+
column: 8
434+
})
435+
);
436+
437+
assert_eq!(
438+
ErrorPosition::find(
439+
"SELECT foo\nbar FROM baz",
440+
PositionBasis::CharPos(16),
441+
),
442+
Some(ErrorPosition {
443+
byte_offset: 16,
444+
char_offset: 16,
445+
line: 2,
446+
column: 5
447+
})
448+
);
449+
450+
assert_eq!(
451+
ErrorPosition::find(
452+
"SELECT foo\r\nbar FROM baz",
453+
PositionBasis::CharPos(17)
454+
),
455+
Some(ErrorPosition {
456+
byte_offset: 16,
457+
char_offset: 16,
458+
line: 2,
459+
column: 5
460+
})
461+
);
408462

409-
test_error_position! {
410-
"SELECT foo", pos: 8, line: 1, col: 8;
411-
"SELECT foo\nbar FROM baz", pos: 16, line: 2, col: 5;
412-
"SELECT foo\r\nbar FROM baz", pos: 17, line: 2, col: 5;
413-
"SELECT foo\r\nbar FROM baz", pos: 27, None;
414-
}
415-
}
463+
assert_eq!(
464+
ErrorPosition::find(
465+
"SELECT foo\r\nbar FROM baz",
466+
PositionBasis::CharPos(27)
467+
),
468+
None
469+
);
470+
}

sqlx-postgres/src/connection/executor.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::describe::Describe;
2-
use crate::error::Error;
2+
use crate::error::{Error, PgResultExt};
33
use crate::executor::{Execute, Executor};
44
use crate::logger::QueryLogger;
55
use crate::message::{
@@ -168,7 +168,9 @@ impl PgConnection {
168168
return Ok((*statement).clone());
169169
}
170170

171-
let statement = prepare(self, sql, parameters, metadata).await?;
171+
let statement = prepare(self, sql, parameters, metadata)
172+
.await
173+
.pg_find_error_pos(sql)?;
172174

173175
if store_to_cache && self.cache_statement.is_enabled() {
174176
if let Some((id, _)) = self.cache_statement.insert(sql, statement.clone()) {
@@ -267,7 +269,9 @@ impl PgConnection {
267269

268270
Ok(try_stream! {
269271
loop {
270-
let message = self.stream.recv().await?;
272+
let message = self.stream.recv()
273+
.await
274+
.pg_find_error_pos(query)?;
271275

272276
match message.format {
273277
MessageFormat::BindComplete

sqlx-postgres/src/connection/stream.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ impl PgStream {
104104
match message.format {
105105
MessageFormat::ErrorResponse => {
106106
// An error returned from the database server.
107-
return Err(PgDatabaseError(message.decode()?).into());
107+
return Err(PgDatabaseError::new(message.decode()?).into());
108108
}
109109

110110
MessageFormat::NotificationResponse => {

0 commit comments

Comments
 (0)