Skip to content

Commit

Permalink
feat: better database errors (#2109)
Browse files Browse the repository at this point in the history
* feat(core): create error kind enum

* feat(core): add error kind for postgres

* feat(core): add error kind for sqlite

* feat(core): add error kind for mysql

* test(postgres): add error tests

* test(sqlite): add error tests

* test(mysql): add error tests

* fix(tests): fix tests rebasing

* refac(errors): add `ErrorKind::Other` variant
  • Loading branch information
saiintbrisson authored Feb 8, 2023
1 parent 5d4bbd5 commit 5e8fff1
Show file tree
Hide file tree
Showing 15 changed files with 451 additions and 67 deletions.
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

0 comments on commit 5e8fff1

Please sign in to comment.