Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better database errors #2109

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/sqlx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ jobs:
--features any,mysql,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
env:
DATABASE_URL: mysql://root:password@localhost:3306/sqlx?ssl-mode=disabled
RUSTFLAGS: --cfg mysql_${{ matrix.mysql }}

# MySQL 5.7 supports TLS but not TLSv1.3 as required by RusTLS.
- uses: actions-rs/cargo@v1
Expand All @@ -282,6 +283,7 @@ jobs:
--features any,mysql,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
env:
DATABASE_URL: mysql://root:password@localhost:3306/sqlx
RUSTFLAGS: --cfg mysql_${{ matrix.mysql }}

mariadb:
name: MariaDB
Expand Down Expand Up @@ -322,3 +324,4 @@ jobs:
--features any,mysql,macros,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }}
env:
DATABASE_URL: mysql://root:password@localhost:3306/sqlx
RUSTFLAGS: --cfg mariadb_${{ matrix.mariadb }}
15 changes: 15 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ name = "sqlite-derives"
path = "tests/sqlite/derives.rs"
required-features = ["sqlite", "macros"]

[[test]]
name = "sqlite-error"
path = "tests/sqlite/error.rs"
required-features = ["sqlite"]

[[test]]
name = "sqlite-sqlcipher"
path = "tests/sqlite/sqlcipher.rs"
Expand Down Expand Up @@ -262,6 +267,11 @@ name = "mysql-macros"
path = "tests/mysql/macros.rs"
required-features = ["mysql", "macros"]

[[test]]
name = "mysql-error"
path = "tests/mysql/error.rs"
required-features = ["mysql"]

[[test]]
name = "mysql-test-attr"
path = "tests/mysql/test-attr.rs"
Expand Down Expand Up @@ -301,6 +311,11 @@ name = "postgres-derives"
path = "tests/postgres/derives.rs"
required-features = ["postgres", "macros"]

[[test]]
name = "postgres-error"
path = "tests/postgres/error.rs"
required-features = ["postgres"]

[[test]]
name = "postgres-test-attr"
path = "tests/postgres/test-attr.rs"
Expand Down
40 changes: 40 additions & 0 deletions sqlx-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,25 @@ pub fn mismatched_types<DB: Database, T: Type<DB>>(ty: &DB::TypeInfo) -> BoxDynE
.into()
}

/// The error kind.
///
/// This enum is to be used to identify frequent errors that can be handled by the program.
/// Although it currently only supports constraint violations, the type may grow in the future.
#[derive(Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorKind {
/// Unique/primary key constraint violation.
UniqueViolation,
/// Foreign key constraint violation.
ForeignKeyViolation,
/// Not-null constraint violation.
NotNullViolation,
/// Check constraint violation.
CheckViolation,
/// An unmapped error.
Other,
}

/// An error that was returned from the database.
pub trait DatabaseError: 'static + Send + Sync + StdError {
/// The primary, human-readable error message.
Expand Down Expand Up @@ -192,6 +211,27 @@ pub trait DatabaseError: 'static + Send + Sync + StdError {
fn constraint(&self) -> Option<&str> {
None
}

/// Returns the kind of the error, if supported.
///
/// ### Note
/// Not all back-ends behave the same when reporting the error code.
fn kind(&self) -> ErrorKind;

/// Returns whether the error kind is a violation of a unique/primary key constraint.
fn is_unique_violation(&self) -> bool {
matches!(self.kind(), ErrorKind::UniqueViolation)
}

/// Returns whether the error kind is a violation of a foreign key.
fn is_foreign_key_violation(&self) -> bool {
matches!(self.kind(), ErrorKind::ForeignKeyViolation)
}

/// Returns whether the error kind is a violation of a check.
fn is_check_violation(&self) -> bool {
matches!(self.kind(), ErrorKind::CheckViolation)
}
}

impl dyn DatabaseError {
Expand Down
76 changes: 76 additions & 0 deletions sqlx-mysql/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,80 @@ impl DatabaseError for MySqlDatabaseError {
fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static> {
self
}

fn kind(&self) -> ErrorKind {
match self.number() {
error_codes::ER_DUP_KEY
| error_codes::ER_DUP_ENTRY
| error_codes::ER_DUP_UNIQUE
| error_codes::ER_DUP_ENTRY_WITH_KEY_NAME
| error_codes::ER_DUP_UNKNOWN_IN_INDEX => ErrorKind::UniqueViolation,

error_codes::ER_NO_REFERENCED_ROW
| error_codes::ER_NO_REFERENCED_ROW_2
| error_codes::ER_ROW_IS_REFERENCED
| error_codes::ER_ROW_IS_REFERENCED_2
| error_codes::ER_FK_COLUMN_NOT_NULL
| error_codes::ER_FK_CANNOT_DELETE_PARENT => ErrorKind::ForeignKeyViolation,

error_codes::ER_BAD_NULL_ERROR | error_codes::ER_NO_DEFAULT_FOR_FIELD => {
ErrorKind::NotNullViolation
}

error_codes::ER_CHECK_CONSTRAINT_VIOLATED => ErrorKind::CheckViolation,

_ => ErrorKind::Other,
}
}
}

/// The MySQL server uses SQLSTATEs as a generic error category,
/// and returns a `error_code` instead within the error packet.
///
/// For reference: <https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html>.
pub(crate) mod error_codes {
/// Caused when a DDL operation creates duplicated keys.
pub const ER_DUP_KEY: u16 = 1022;
/// Caused when a DML operation tries create a duplicated entry for a key,
/// be it a unique or primary one.
pub const ER_DUP_ENTRY: u16 = 1062;
/// Similar to `ER_DUP_ENTRY`, but only present in NDB clusters.
///
/// See: <https://github.com/mysql/mysql-server/blob/fbdaa4def30d269bc4de5b85de61de34b11c0afc/mysql-test/suite/stress/include/ddl7.inc#L68>.
pub const ER_DUP_UNIQUE: u16 = 1169;
/// Similar to `ER_DUP_ENTRY`, but with a formatted string message.
///
/// See: <https://bugs.mysql.com/bug.php?id=46976>.
pub const ER_DUP_ENTRY_WITH_KEY_NAME: u16 = 1586;
/// Caused when a DDL operation to add a unique index fails,
/// because duplicate items were created by concurrent DML operations.
/// When this happens, the key is unknown, so the server can't use `ER_DUP_KEY`.
///
/// For example: an `INSERT` operation creates duplicate `name` fields when `ALTER`ing a table and making `name` unique.
pub const ER_DUP_UNKNOWN_IN_INDEX: u16 = 1859;

/// Caused when inserting an entry with a column with a value that does not reference a foreign row.
pub const ER_NO_REFERENCED_ROW: u16 = 1216;
/// Caused when deleting a row that is referenced in other tables.
pub const ER_ROW_IS_REFERENCED: u16 = 1217;
/// Caused when deleting a row that is referenced in other tables.
/// This differs from `ER_ROW_IS_REFERENCED` in that the error message contains the affected constraint.
pub const ER_ROW_IS_REFERENCED_2: u16 = 1451;
/// Caused when inserting an entry with a column with a value that does not reference a foreign row.
/// This differs from `ER_NO_REFERENCED_ROW` in that the error message contains the affected constraint.
pub const ER_NO_REFERENCED_ROW_2: u16 = 1452;
/// Caused when creating a FK with `ON DELETE SET NULL` or `ON UPDATE SET NULL` to a column that is `NOT NULL`, or vice-versa.
pub const ER_FK_COLUMN_NOT_NULL: u16 = 1830;
/// Removed in 5.7.3.
pub const ER_FK_CANNOT_DELETE_PARENT: u16 = 1834;

/// Caused when inserting a NULL value to a column marked as NOT NULL.
pub const ER_BAD_NULL_ERROR: u16 = 1048;
/// Caused when inserting a DEFAULT value to a column marked as NOT NULL, which also doesn't have a default value set.
pub const ER_NO_DEFAULT_FOR_FIELD: u16 = 1364;

/// Caused when a check constraint is violated.
///
/// Only available after 8.0.16.
pub const ER_CHECK_CONSTRAINT_VIOLATED: u16 = 3819;
}
22 changes: 22 additions & 0 deletions sqlx-postgres/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,26 @@ impl DatabaseError for PgDatabaseError {
fn constraint(&self) -> Option<&str> {
self.constraint()
}

fn kind(&self) -> ErrorKind {
match self.code() {
error_codes::UNIQUE_VIOLATION => ErrorKind::UniqueViolation,
error_codes::FOREIGN_KEY_VIOLATION => ErrorKind::ForeignKeyViolation,
error_codes::NOT_NULL_VIOLATION => ErrorKind::NotNullViolation,
error_codes::CHECK_VIOLATION => ErrorKind::CheckViolation,
_ => ErrorKind::Other,
}
}
}

/// For reference: <https://www.postgresql.org/docs/current/errcodes-appendix.html>
pub(crate) mod error_codes {
/// Caused when a unique or primary key is violated.
pub const UNIQUE_VIOLATION: &str = "23505";
/// Caused when a foreign key is violated.
pub const FOREIGN_KEY_VIOLATION: &str = "23503";
/// Caused when a column marked as NOT NULL received a null value.
pub const NOT_NULL_VIOLATION: &str = "23502";
/// Caused when a check constraint is violated.
pub const CHECK_VIOLATION: &str = "23514";
}
16 changes: 15 additions & 1 deletion sqlx-sqlite/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ use std::fmt::{self, Display, Formatter};
use std::os::raw::c_int;
use std::{borrow::Cow, str::from_utf8_unchecked};

use libsqlite3_sys::{sqlite3, sqlite3_errmsg, sqlite3_extended_errcode};
use libsqlite3_sys::{
sqlite3, sqlite3_errmsg, sqlite3_extended_errcode, SQLITE_CONSTRAINT_CHECK,
SQLITE_CONSTRAINT_FOREIGNKEY, SQLITE_CONSTRAINT_NOTNULL, SQLITE_CONSTRAINT_PRIMARYKEY,
SQLITE_CONSTRAINT_UNIQUE,
};

pub(crate) use sqlx_core::error::*;

Expand Down Expand Up @@ -82,4 +86,14 @@ impl DatabaseError for SqliteError {
fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static> {
self
}

fn kind(&self) -> ErrorKind {
match self.code {
SQLITE_CONSTRAINT_UNIQUE | SQLITE_CONSTRAINT_PRIMARYKEY => ErrorKind::UniqueViolation,
SQLITE_CONSTRAINT_FOREIGNKEY => ErrorKind::ForeignKeyViolation,
SQLITE_CONSTRAINT_NOTNULL => ErrorKind::NotNullViolation,
SQLITE_CONSTRAINT_CHECK => ErrorKind::CheckViolation,
_ => ErrorKind::Other,
}
}
}
41 changes: 0 additions & 41 deletions tests/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,44 +173,3 @@ services:
- "./postgres/setup.sql:/docker-entrypoint-initdb.d/setup.sql"
command: >
-c ssl=on -c ssl_cert_file=/var/lib/postgresql/server.crt -c ssl_key_file=/var/lib/postgresql/server.key
#
# Microsoft SQL Server (MSSQL)
# https://hub.docker.com/_/microsoft-mssql-server
#

mssql_2019:
build:
context: .
dockerfile: mssql/Dockerfile
args:
VERSION: 2019-latest
ports:
- 1433
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: Password123!

mssql_2017:
build:
context: .
dockerfile: mssql/mssql-2017.dockerfile
args:
VERSION: 2017-latest
ports:
- 1433
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: Password123!

#
# IBM Db2
# https://hub.docker.com/r/ibmcom/db2
#

db2_11:
image: ibmcom/db2:11.5.1.0-CN1
privileged: true
environment:
LICENSE: accept
DB2INST1_PASSWORD: password
DBNAME: sqlx
77 changes: 77 additions & 0 deletions tests/mysql/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use sqlx::{error::ErrorKind, mysql::MySql, Connection};
use sqlx_test::new;

#[sqlx_macros::test]
async fn it_fails_with_unique_violation() -> anyhow::Result<()> {
let mut conn = new::<MySql>().await?;
let mut tx = conn.begin().await?;

sqlx::query("INSERT INTO tweet(id, text, owner_id) VALUES (1, 'Foo', 1)")
.execute(&mut *tx)
.await?;

let res: Result<_, sqlx::Error> = sqlx::query("INSERT INTO tweet VALUES (1, NOW(), 'Foo', 1);")
.execute(&mut *tx)
.await;
let err = res.unwrap_err();

let err = err.into_database_error().unwrap();

assert_eq!(err.kind(), ErrorKind::UniqueViolation);

Ok(())
}

#[sqlx_macros::test]
async fn it_fails_with_foreign_key_violation() -> anyhow::Result<()> {
let mut conn = new::<MySql>().await?;
let mut tx = conn.begin().await?;

let res: Result<_, sqlx::Error> =
sqlx::query("INSERT INTO tweet_reply (tweet_id, text) VALUES (1, 'Reply!');")
.execute(&mut *tx)
.await;
let err = res.unwrap_err();

let err = err.into_database_error().unwrap();

assert_eq!(err.kind(), ErrorKind::ForeignKeyViolation);

Ok(())
}

#[sqlx_macros::test]
async fn it_fails_with_not_null_violation() -> anyhow::Result<()> {
let mut conn = new::<MySql>().await?;
let mut tx = conn.begin().await?;

let res: Result<_, sqlx::Error> = sqlx::query("INSERT INTO tweet (text) VALUES (null);")
.execute(&mut *tx)
.await;
let err = res.unwrap_err();

let err = err.into_database_error().unwrap();

assert_eq!(err.kind(), ErrorKind::NotNullViolation);

Ok(())
}

#[cfg(mysql_8)]
#[sqlx_macros::test]
async fn it_fails_with_check_violation() -> anyhow::Result<()> {
let mut conn = new::<MySql>().await?;
let mut tx = conn.begin().await?;

let res: Result<_, sqlx::Error> =
sqlx::query("INSERT INTO products VALUES (1, 'Product 1', 0);")
.execute(&mut *tx)
.await;
let err = res.unwrap_err();

let err = err.into_database_error().unwrap();

assert_eq!(err.kind(), ErrorKind::CheckViolation);

Ok(())
}
16 changes: 16 additions & 0 deletions tests/mysql/setup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,19 @@ CREATE TABLE tweet
text TEXT NOT NULL,
owner_id BIGINT
);

CREATE TABLE tweet_reply
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tweet_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
text TEXT NOT NULL,
owner_id BIGINT,
CONSTRAINT tweet_id_fk FOREIGN KEY (tweet_id) REFERENCES tweet(id)
);

CREATE TABLE products (
product_no INTEGER,
name TEXT,
price NUMERIC CHECK (price > 0)
);
Loading