Skip to main content

orbit_orm/
error.rs

1use std::path::PathBuf;
2
3use sqlx::error::ErrorKind;
4#[cfg(feature = "mysql")]
5use sqlx::mysql::MySqlDatabaseError;
6
7/// MySQL error 1205: a lock was not released within `innodb_lock_wait_timeout`.
8#[cfg(feature = "mysql")]
9const ER_LOCK_WAIT_TIMEOUT: u16 = 1205;
10/// MySQL error 1213: the database rolled back the transaction to break a deadlock.
11#[cfg(feature = "mysql")]
12const ER_LOCK_DEADLOCK: u16 = 1213;
13/// PostgreSQL SQLSTATE 40P01: the database rolled back the transaction to break a deadlock.
14#[cfg(feature = "postgres")]
15const PG_DEADLOCK_DETECTED: &str = "40P01";
16/// PostgreSQL SQLSTATE 55P03: `lock_not_available`, a `NOWAIT` or `lock_timeout` lock failed.
17#[cfg(feature = "postgres")]
18const PG_LOCK_NOT_AVAILABLE: &str = "55P03";
19
20/// Any error from Orbit. Driver errors that have a variant here are classified; the rest come
21/// through unchanged as `Database`.
22#[derive(Debug, thiserror::Error)]
23pub enum Error {
24    /// The database rejected a connection, a query, or a statement, and no other variant fits.
25    #[error(transparent)]
26    Database(sqlx::Error),
27
28    /// An insert or update broke a unique index or primary key. `index` is the index name when
29    /// the server reports it.
30    #[error("unique index `{}` was violated", .index.as_deref().unwrap_or("unknown"))]
31    UniqueViolation { index: Option<String> },
32
33    /// A row refers to a missing parent, or a parent is still referenced. `constraint` is the
34    /// foreign key name when the server reports it.
35    #[error("foreign key `{}` was violated", .constraint.as_deref().unwrap_or("unknown"))]
36    ForeignKeyViolation { constraint: Option<String> },
37
38    /// The database rolled back the whole transaction to break a deadlock. Run it again.
39    #[error("deadlock found; the transaction was rolled back")]
40    Deadlock,
41
42    /// A lock was not released in time. Only the statement failed; the transaction is still open.
43    #[error("lock wait timeout; the statement was rolled back")]
44    LockWaitTimeout,
45
46    /// A file could not be read, such as a migration's `up.sql`.
47    #[error("could not read {}: {source}", path.display())]
48    Read {
49        path: PathBuf,
50        source: std::io::Error,
51    },
52
53    /// Two migrations have the same name.
54    #[error("migration `{name}` is defined more than once")]
55    DuplicateMigration { name: String },
56
57    /// A migration name does not start with a 14-digit timestamp and an underscore.
58    #[error("migration `{name}` must start with a 14-digit timestamp, such as 20261108000000_name")]
59    InvalidMigrationName { name: String },
60
61    /// Two migrations share a timestamp, so their order is not defined.
62    #[error("migrations `{first}` and `{second}` have the same timestamp")]
63    DuplicateTimestamp { first: String, second: String },
64
65    /// Another process is running migrations and did not finish within the lock timeout.
66    #[error("another process is running migrations; gave up after {seconds} seconds")]
67    MigrationsLocked { seconds: u64 },
68
69    /// A migration's `up.sql` failed. The run stopped there; earlier migrations stay applied.
70    #[error("migration `{name}` failed, so the run stopped: {source}")]
71    MigrationFailed { name: String, source: sqlx::Error },
72
73    /// A migration's `down.sql` failed. The rollback stopped there.
74    #[error("migration `{name}` failed to roll back: {source}")]
75    RollbackFailed { name: String, source: sqlx::Error },
76
77    /// A migration is recorded as applied, but this migrator has no folder for it, so it cannot
78    /// be rolled back. `name` is the version: the 14-digit timestamp prefix recorded in the table.
79    #[error(
80        "migration version `{name}` was recorded as applied, but no folder with that version \
81         exists in this migrator, so it cannot be rolled back"
82    )]
83    UnknownMigration { name: String },
84
85    /// A table or column name is empty or contains a quote character (a backtick or a double
86    /// quote). The same rule applies to every database.
87    #[error(
88        "invalid identifier `{0}`: names must not be empty or contain a backtick or a double quote"
89    )]
90    InvalidIdentifier(String),
91
92    /// The URL's scheme names a database whose Cargo feature is not enabled, or no database.
93    /// `scheme` is the part before the first `:`, never the password.
94    #[error(
95        "database URL scheme `{scheme}` is not supported by this build; enable its Cargo feature"
96    )]
97    UnsupportedDriver { scheme: String },
98
99    /// The database has no equivalent for this operation. `operation` names it and `driver`
100    /// names the database. See `docs/laravel-differences.md` for each case.
101    #[error("{operation} is not supported on {driver}")]
102    Unsupported {
103        operation: &'static str,
104        driver: &'static str,
105    },
106
107    /// A column is not in the model's `FILLABLE` list.
108    #[error("column `{column}` is not fillable on table `{table}`")]
109    NotFillable { table: &'static str, column: String },
110
111    /// A query that must find a row found none.
112    #[error("no query results for table `{table}`")]
113    NotFound { table: &'static str },
114
115    /// A blocking call ran on a thread that runs async code. Run it inside `spawn_blocking`.
116    #[error("blocking call on an async thread; run it inside tokio::task::spawn_blocking")]
117    BlockingInRuntime,
118}
119
120impl From<sqlx::Error> for Error {
121    /// Classifies the driver error by its kind, then by the driver's error code where the kind
122    /// does not say enough. Anything else is kept as `Database`.
123    fn from(error: sqlx::Error) -> Self {
124        if let sqlx::Error::Database(db) = &error {
125            match db.kind() {
126                ErrorKind::UniqueViolation => {
127                    return Self::UniqueViolation {
128                        index: violated_name(db.as_ref(), true),
129                    };
130                }
131                ErrorKind::ForeignKeyViolation => {
132                    return Self::ForeignKeyViolation {
133                        constraint: violated_name(db.as_ref(), false),
134                    };
135                }
136                _ => {}
137            }
138            if let Some(kind) = lock_error(db.as_ref()) {
139                return kind;
140            }
141        }
142        Self::Database(error)
143    }
144}
145
146/// The name of the violated index (`unique`) or foreign key. MySQL reports it only in the message,
147/// so that is read. The other drivers return it as the constraint name; SQLite returns none.
148#[cfg_attr(not(feature = "mysql"), allow(unused_variables))]
149fn violated_name(db: &dyn sqlx::error::DatabaseError, unique: bool) -> Option<String> {
150    #[cfg(feature = "mysql")]
151    if db.try_downcast_ref::<MySqlDatabaseError>().is_some() {
152        return if unique {
153            duplicate_key(db.message())
154        } else {
155            foreign_key(db.message())
156        };
157    }
158    db.constraint().map(str::to_owned)
159}
160
161/// The deadlock or lock-timeout error for the driver's code, if this is one.
162#[cfg_attr(
163    not(any(feature = "mysql", feature = "postgres")),
164    allow(unused_variables)
165)]
166fn lock_error(db: &dyn sqlx::error::DatabaseError) -> Option<Error> {
167    #[cfg(feature = "mysql")]
168    if let Some(mysql) = db.try_downcast_ref::<MySqlDatabaseError>() {
169        match mysql.number() {
170            ER_LOCK_DEADLOCK => return Some(Error::Deadlock),
171            ER_LOCK_WAIT_TIMEOUT => return Some(Error::LockWaitTimeout),
172            _ => {}
173        }
174    }
175    // The driver maps lock errors to `Other`, so they are matched by their code.
176    #[cfg(feature = "postgres")]
177    match db.code().as_deref() {
178        Some(PG_DEADLOCK_DETECTED) => return Some(Error::Deadlock),
179        Some(PG_LOCK_NOT_AVAILABLE) => return Some(Error::LockWaitTimeout),
180        _ => {}
181    }
182    None
183}
184
185/// The index in a "Duplicate entry 'value' for key 'index'" message. MySQL 8.0.19 and later
186/// prefix the table name, as in `table.index`, which is removed here.
187#[cfg(feature = "mysql")]
188fn duplicate_key(message: &str) -> Option<String> {
189    const MARK: &str = "for key '";
190    let start = message.rfind(MARK)? + MARK.len();
191    let key = message[start..].strip_suffix('\'')?;
192    Some(key.rsplit('.').next().unwrap_or(key).to_owned())
193}
194
195/// The constraint in a "... CONSTRAINT `name` FOREIGN KEY ..." message.
196#[cfg(feature = "mysql")]
197fn foreign_key(message: &str) -> Option<String> {
198    const MARK: &str = "CONSTRAINT `";
199    let start = message.find(MARK)? + MARK.len();
200    let rest = &message[start..];
201    Some(rest[..rest.find('`')?].to_owned())
202}
203
204#[cfg(all(test, feature = "mysql"))]
205mod tests {
206    use super::{duplicate_key, foreign_key};
207
208    #[test]
209    fn duplicate_key_reads_the_index_name() {
210        assert_eq!(
211            duplicate_key("Duplicate entry 'a@example.com' for key 'users_email_unique'"),
212            Some("users_email_unique".to_owned())
213        );
214    }
215
216    #[test]
217    fn duplicate_key_drops_the_table_prefix_of_mysql_8() {
218        assert_eq!(
219            duplicate_key("Duplicate entry '1' for key 'users.PRIMARY'"),
220            Some("PRIMARY".to_owned())
221        );
222    }
223
224    #[test]
225    fn duplicate_key_ignores_a_value_that_looks_like_the_marker() {
226        assert_eq!(
227            duplicate_key("Duplicate entry 'x' for key 'y' for key 'real_index'"),
228            Some("real_index".to_owned())
229        );
230    }
231
232    #[test]
233    fn duplicate_key_is_none_without_a_key() {
234        assert_eq!(
235            duplicate_key("Can't write, because of unique constraint 'x'"),
236            None
237        );
238    }
239
240    #[test]
241    fn foreign_key_reads_the_constraint_name() {
242        assert_eq!(
243            foreign_key(
244                "Cannot add or update a child row: a foreign key constraint fails (`db`.`child`, \
245                 CONSTRAINT `fk_child_parent` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`))"
246            ),
247            Some("fk_child_parent".to_owned())
248        );
249        assert_eq!(foreign_key("something else"), None);
250    }
251}