From fe6c40dd75a8a980009623e7b84e56974ba026b1 Mon Sep 17 00:00:00 2001 From: mohs8421 Date: Sun, 28 Aug 2022 05:13:51 +0200 Subject: [PATCH 01/71] Introducing sqlx-error feature (#750) * feat: Introducing feature "sqlx-error" Purpose of this feature is to not convert errors given from sqlx into strings to ease further analysis of the error and react to it accordingly. This implementation uses a feature switch and an additional error kind to avoid interfering with existing implementations without this feature enabled. See discussion https://github.com/SeaQL/sea-orm/discussions/709 * fix: Align feature "sqlx-error" with merged Migration error kind Due to the merge, an other error kind had been introduced and the DbErr became Eq and Clone, however Eq cannot easily be derived from, so I went back to PartialEq, and since the sqlx error does not implement clone, this was converted into an Arc, to allow cloning of the new kind too. * fix: Repairing failing jobs Several jobs had failed as I missed to correct the return values of a few methods in transaction.rs and had a wrong understanding of map_err at that point. * feat: realigning with latest changes in sea-orm, different approach Instead of the former approach to introduce a new error kind, now the existing error types get extended, for now only Exec and Query, because these are the most relevant for the requirement context. Afterwards it might still be possible to add some further detail information. See discussion https://github.com/SeaQL/sea-orm/discussions/709 * Update src/driver/sqlx_mysql.rs Integrating fixes done by @Sculas Co-authored-by: Sculas * Update src/driver/sqlx_postgres.rs Integrating fixes done by @Sculas Co-authored-by: Sculas * Update src/driver/sqlx_sqlite.rs Integrating fixes done by @Sculas Co-authored-by: Sculas * feat: reworking feature with thiserror Following the latest suggestions I changed the implementation to utilize `thiserror`. Now there are more error kinds to be able to see the different kinds directly in src/error.rs To ensure the behaviour is as expected, I also introduce a further test, which checks for a uniqueness failure. Co-authored-by: Sculas --- Cargo.toml | 1 + issues/324/src/model.rs | 5 +--- issues/400/src/model.rs | 7 ++--- src/database/mock.rs | 2 +- src/database/transaction.rs | 49 +++++++++++++++++++------------ src/driver/sqlx_common.rs | 6 ++-- src/driver/sqlx_mysql.rs | 28 +++++------------- src/driver/sqlx_postgres.rs | 28 +++++------------- src/driver/sqlx_sqlite.rs | 28 +++++------------- src/error.rs | 58 +++++++++++++++++++++++++++---------- src/executor/insert.rs | 6 ++-- src/executor/query.rs | 29 +++++++------------ src/executor/update.rs | 6 ++-- tests/crud/error.rs | 56 +++++++++++++++++++++++++++++++++++ tests/crud/mod.rs | 2 ++ tests/crud_tests.rs | 1 + 16 files changed, 183 insertions(+), 129 deletions(-) create mode 100644 tests/crud/error.rs diff --git a/Cargo.toml b/Cargo.toml index 277a2a68..a2c0384f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ uuid = { version = "^1", features = ["serde", "v4"], optional = true } ouroboros = "0.15" url = "^2.2" once_cell = "1.8" +thiserror = "^1" [dev-dependencies] smol = { version = "^1.2" } diff --git a/issues/324/src/model.rs b/issues/324/src/model.rs index 9ec35a07..37174db9 100644 --- a/issues/324/src/model.rs +++ b/issues/324/src/model.rs @@ -26,10 +26,7 @@ macro_rules! impl_try_from_u64_err { ($newtype: ident) => { impl sea_orm::TryFromU64 for $newtype { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!($newtype) - ))) + Err(sea_orm::ConvertFromU64(stringify!($newtype))) } } }; diff --git a/issues/400/src/model.rs b/issues/400/src/model.rs index a53eeb74..6d9044c4 100644 --- a/issues/400/src/model.rs +++ b/issues/400/src/model.rs @@ -1,5 +1,5 @@ -use std::marker::PhantomData; use sea_orm::entity::prelude::*; +use std::marker::PhantomData; #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "model")] @@ -31,10 +31,7 @@ impl From> for Uuid { impl sea_orm::TryFromU64 for AccountId { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!(AccountId) - ))) + Err(sea_orm::ConvertFromU64(stringify!(AccountId))) } } diff --git a/src/database/mock.rs b/src/database/mock.rs index 7af7cdb5..a1dbc795 100644 --- a/src/database/mock.rs +++ b/src/database/mock.rs @@ -102,7 +102,7 @@ impl MockDatabaseTrait for MockDatabase { result: ExecResultHolder::Mock(std::mem::take(&mut self.exec_results[counter])), }) } else { - Err(DbErr::Exec("`exec_results` buffer is empty.".to_owned())) + Err(DbErr::Query("`exec_results` buffer is empty.".to_owned())) } } diff --git a/src/database/transaction.rs b/src/database/transaction.rs index cb1ad122..66ae25e5 100644 --- a/src/database/transaction.rs +++ b/src/database/transaction.rs @@ -238,6 +238,17 @@ impl DatabaseTransaction { } } } + + #[cfg(feature = "sqlx-dep")] + fn map_err_ignore_not_found( + err: Result, sqlx::Error>, + ) -> Result, DbErr> { + if let Err(sqlx::Error::RowNotFound) = err { + Ok(None) + } else { + err.map_err(|e| sqlx_error_to_query_err(e)) + } + } } impl Drop for DatabaseTransaction { @@ -258,13 +269,14 @@ impl ConnectionTrait for DatabaseTransaction { async fn execute(&self, stmt: Statement) -> Result { debug_print!("{}", stmt); - let _res = match &mut *self.conn.lock().await { + match &mut *self.conn.lock().await { #[cfg(feature = "sqlx-mysql")] InnerConnection::MySql(conn) => { let query = crate::driver::sqlx_mysql::sqlx_query(&stmt); crate::metric::metric!(self.metric_callback, &stmt, { query.execute(conn).await.map(Into::into) }) + .map_err(sqlx_error_to_exec_err) } #[cfg(feature = "sqlx-postgres")] InnerConnection::Postgres(conn) => { @@ -272,6 +284,7 @@ impl ConnectionTrait for DatabaseTransaction { crate::metric::metric!(self.metric_callback, &stmt, { query.execute(conn).await.map(Into::into) }) + .map_err(sqlx_error_to_exec_err) } #[cfg(feature = "sqlx-sqlite")] InnerConnection::Sqlite(conn) => { @@ -279,14 +292,13 @@ impl ConnectionTrait for DatabaseTransaction { crate::metric::metric!(self.metric_callback, &stmt, { query.execute(conn).await.map(Into::into) }) + .map_err(sqlx_error_to_exec_err) } #[cfg(feature = "mock")] InnerConnection::Mock(conn) => return conn.execute(stmt), #[allow(unreachable_patterns)] _ => unreachable!(), - }; - #[cfg(feature = "sqlx-dep")] - _res.map_err(sqlx_error_to_exec_err) + } } #[instrument(level = "trace")] @@ -294,32 +306,32 @@ impl ConnectionTrait for DatabaseTransaction { async fn query_one(&self, stmt: Statement) -> Result, DbErr> { debug_print!("{}", stmt); - let _res = match &mut *self.conn.lock().await { + match &mut *self.conn.lock().await { #[cfg(feature = "sqlx-mysql")] InnerConnection::MySql(conn) => { let query = crate::driver::sqlx_mysql::sqlx_query(&stmt); - query.fetch_one(conn).await.map(|row| Some(row.into())) + Self::map_err_ignore_not_found( + query.fetch_one(conn).await.map(|row| Some(row.into())), + ) } #[cfg(feature = "sqlx-postgres")] InnerConnection::Postgres(conn) => { let query = crate::driver::sqlx_postgres::sqlx_query(&stmt); - query.fetch_one(conn).await.map(|row| Some(row.into())) + Self::map_err_ignore_not_found( + query.fetch_one(conn).await.map(|row| Some(row.into())), + ) } #[cfg(feature = "sqlx-sqlite")] InnerConnection::Sqlite(conn) => { let query = crate::driver::sqlx_sqlite::sqlx_query(&stmt); - query.fetch_one(conn).await.map(|row| Some(row.into())) + Self::map_err_ignore_not_found( + query.fetch_one(conn).await.map(|row| Some(row.into())), + ) } #[cfg(feature = "mock")] InnerConnection::Mock(conn) => return conn.query_one(stmt), #[allow(unreachable_patterns)] _ => unreachable!(), - }; - #[cfg(feature = "sqlx-dep")] - if let Err(sqlx::Error::RowNotFound) = _res { - Ok(None) - } else { - _res.map_err(sqlx_error_to_query_err) } } @@ -328,7 +340,7 @@ impl ConnectionTrait for DatabaseTransaction { async fn query_all(&self, stmt: Statement) -> Result, DbErr> { debug_print!("{}", stmt); - let _res = match &mut *self.conn.lock().await { + match &mut *self.conn.lock().await { #[cfg(feature = "sqlx-mysql")] InnerConnection::MySql(conn) => { let query = crate::driver::sqlx_mysql::sqlx_query(&stmt); @@ -336,6 +348,7 @@ impl ConnectionTrait for DatabaseTransaction { .fetch_all(conn) .await .map(|rows| rows.into_iter().map(|r| r.into()).collect()) + .map_err(sqlx_error_to_query_err) } #[cfg(feature = "sqlx-postgres")] InnerConnection::Postgres(conn) => { @@ -344,6 +357,7 @@ impl ConnectionTrait for DatabaseTransaction { .fetch_all(conn) .await .map(|rows| rows.into_iter().map(|r| r.into()).collect()) + .map_err(sqlx_error_to_query_err) } #[cfg(feature = "sqlx-sqlite")] InnerConnection::Sqlite(conn) => { @@ -352,14 +366,13 @@ impl ConnectionTrait for DatabaseTransaction { .fetch_all(conn) .await .map(|rows| rows.into_iter().map(|r| r.into()).collect()) + .map_err(sqlx_error_to_query_err) } #[cfg(feature = "mock")] InnerConnection::Mock(conn) => return conn.query_all(stmt), #[allow(unreachable_patterns)] _ => unreachable!(), - }; - #[cfg(feature = "sqlx-dep")] - _res.map_err(sqlx_error_to_query_err) + } } } diff --git a/src/driver/sqlx_common.rs b/src/driver/sqlx_common.rs index 18ca059d..91509813 100644 --- a/src/driver/sqlx_common.rs +++ b/src/driver/sqlx_common.rs @@ -2,15 +2,15 @@ use crate::DbErr; /// Converts an [sqlx::error] execution error to a [DbErr] pub fn sqlx_error_to_exec_err(err: sqlx::Error) -> DbErr { - DbErr::Exec(err.to_string()) + DbErr::ExecSqlX(err) } /// Converts an [sqlx::error] query error to a [DbErr] pub fn sqlx_error_to_query_err(err: sqlx::Error) -> DbErr { - DbErr::Query(err.to_string()) + DbErr::QuerySqlX(err) } /// Converts an [sqlx::error] connection error to a [DbErr] pub fn sqlx_error_to_conn_err(err: sqlx::Error) -> DbErr { - DbErr::Conn(err.to_string()) + DbErr::ConnSqlX(err) } diff --git a/src/driver/sqlx_mysql.rs b/src/driver/sqlx_mysql.rs index 01b840dd..e1f1d416 100644 --- a/src/driver/sqlx_mysql.rs +++ b/src/driver/sqlx_mysql.rs @@ -45,7 +45,7 @@ impl SqlxMySqlConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::Conn(e.to_string()))?; + .map_err(|e| DbErr::ConnSqlX(e))?; use sqlx::ConnectOptions; if !options.sqlx_logging { opt.disable_statement_logging(); @@ -89,9 +89,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::Exec( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -107,14 +105,12 @@ impl SqlxMySqlPoolConnection { Ok(row) => Ok(Some(row.into())), Err(err) => match err { sqlx::Error::RowNotFound => Ok(None), - _ => Err(DbErr::Query(err.to_string())), + _ => Err(sqlx_error_to_query_err(err)), }, } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -132,9 +128,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -150,9 +144,7 @@ impl SqlxMySqlPoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -162,9 +154,7 @@ impl SqlxMySqlPoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_mysql(conn, self.metric_callback.clone()).await } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -185,9 +175,7 @@ impl SqlxMySqlPoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - ))) + Err(TransactionError::Connection(DbErr::ConnFromPool)) } } diff --git a/src/driver/sqlx_postgres.rs b/src/driver/sqlx_postgres.rs index bd561148..6d070115 100644 --- a/src/driver/sqlx_postgres.rs +++ b/src/driver/sqlx_postgres.rs @@ -45,7 +45,7 @@ impl SqlxPostgresConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::Conn(e.to_string()))?; + .map_err(|e| DbErr::ConnSqlX(e))?; use sqlx::ConnectOptions; if !options.sqlx_logging { opt.disable_statement_logging(); @@ -89,9 +89,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::Exec( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -107,14 +105,12 @@ impl SqlxPostgresPoolConnection { Ok(row) => Ok(Some(row.into())), Err(err) => match err { sqlx::Error::RowNotFound => Ok(None), - _ => Err(DbErr::Query(err.to_string())), + _ => Err(sqlx_error_to_query_err(err)), }, } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -132,9 +128,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -150,9 +144,7 @@ impl SqlxPostgresPoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -162,9 +154,7 @@ impl SqlxPostgresPoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_postgres(conn, self.metric_callback.clone()).await } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -185,9 +175,7 @@ impl SqlxPostgresPoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - ))) + Err(TransactionError::Connection(DbErr::ConnFromPool)) } } diff --git a/src/driver/sqlx_sqlite.rs b/src/driver/sqlx_sqlite.rs index 8bf56214..55e768b0 100644 --- a/src/driver/sqlx_sqlite.rs +++ b/src/driver/sqlx_sqlite.rs @@ -46,7 +46,7 @@ impl SqlxSqliteConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::Conn(e.to_string()))?; + .map_err(|e| DbErr::ConnSqlX(e))?; if options.sqlcipher_key.is_some() { opt = opt.pragma("key", options.sqlcipher_key.clone().unwrap()); } @@ -96,9 +96,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::Exec( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -114,14 +112,12 @@ impl SqlxSqlitePoolConnection { Ok(row) => Ok(Some(row.into())), Err(err) => match err { sqlx::Error::RowNotFound => Ok(None), - _ => Err(DbErr::Query(err.to_string())), + _ => Err(sqlx_error_to_query_err(err)), }, } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -139,9 +135,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -157,9 +151,7 @@ impl SqlxSqlitePoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -169,9 +161,7 @@ impl SqlxSqlitePoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_sqlite(conn, self.metric_callback.clone()).await } else { - Err(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - )) + Err(DbErr::ConnFromPool) } } @@ -192,9 +182,7 @@ impl SqlxSqlitePoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::Query( - "Failed to acquire connection from pool.".to_owned(), - ))) + Err(TransactionError::Connection(DbErr::ConnFromPool)) } } diff --git a/src/error.rs b/src/error.rs index aecc3f52..30b2909d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,38 +1,64 @@ +#[cfg(feature = "sqlx-dep")] +use sqlx::error::Error as SqlxError; +use thiserror::Error; + /// An error from unsuccessful database operations -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Error, Debug)] pub enum DbErr { + /// This error happens, when a pool was not able to create a connection + #[error("Failed to acquire connection from pool.")] + ConnFromPool, + /// Error in case of invalid type conversion attempts + #[error("fail to convert '{0}' into '{1}'")] + CannotConvertInto(String, String), + /// Error in case of invalid type conversion from an u64 + #[error("{0} cannot be converted from u64")] + ConvertFromU64(String), + /// After an insert statement it was impossible to retrieve the last_insert_id + #[error("Fail to unpack last_insert_id")] + InsertCouldNotUnpackInsertId, + /// When updating, a model should know it's primary key to check + /// if the record has been correctly updated, otherwise this error will occur + #[error("Fail to get primary key from model")] + UpdateCouldNotGetPrimaryKey, /// There was a problem with the database connection + #[error("Connection Error: {0}")] Conn(String), + /// There was a problem with the database connection from sqlx + #[cfg(feature = "sqlx-dep")] + #[error("Connection Error: {0}")] + ConnSqlX(#[source] SqlxError), /// An operation did not execute successfully - Exec(String), + #[cfg(feature = "sqlx-dep")] + #[error("Execution Error: {0}")] + ExecSqlX(#[source] SqlxError), + /// An error occurred while performing a query, with more details from sqlx + #[cfg(feature = "sqlx-dep")] + #[error("Query Error: {0}")] + QuerySqlX(#[source] SqlxError), /// An error occurred while performing a query + #[error("Query Error: {0}")] Query(String), /// The record was not found in the database + #[error("RecordNotFound Error: {0}")] RecordNotFound(String), /// A custom error + #[error("Custom Error: {0}")] Custom(String), /// Error occurred while parsing value as target type + #[error("Type Error: {0}")] Type(String), /// Error occurred while parsing json value as target type + #[error("Json Error: {0}")] Json(String), /// A migration error + #[error("Migration Error: {0}")] Migration(String), } -impl std::error::Error for DbErr {} - -impl std::fmt::Display for DbErr { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Conn(s) => write!(f, "Connection Error: {}", s), - Self::Exec(s) => write!(f, "Execution Error: {}", s), - Self::Query(s) => write!(f, "Query Error: {}", s), - Self::RecordNotFound(s) => write!(f, "RecordNotFound Error: {}", s), - Self::Custom(s) => write!(f, "Custom Error: {}", s), - Self::Type(s) => write!(f, "Type Error: {}", s), - Self::Json(s) => write!(f, "Json Error: {}", s), - Self::Migration(s) => write!(f, "Migration Error: {}", s), - } +impl PartialEq for DbErr { + fn eq(&self, other: &Self) -> bool { + self.to_string() == other.to_string() } } diff --git a/src/executor/insert.rs b/src/executor/insert.rs index 5f3f9c95..f7bb0629 100644 --- a/src/executor/insert.rs +++ b/src/executor/insert.rs @@ -130,7 +130,7 @@ where Some(value_tuple) => FromValueTuple::from_value_tuple(value_tuple), None => match last_insert_id_opt { Some(last_insert_id) => last_insert_id, - None => return Err(DbErr::Exec("Fail to unpack last_insert_id".to_owned())), + None => return Err(DbErr::InsertCouldNotUnpackInsertId), }, }; Ok(InsertResult { last_insert_id }) @@ -176,6 +176,8 @@ where }; match found { Some(model) => Ok(model), - None => Err(DbErr::Exec("Failed to find inserted item".to_owned())), + None => Err(DbErr::RecordNotFound( + "Failed to find inserted item".to_owned(), + )), } } diff --git a/src/executor/query.rs b/src/executor/query.rs index 06361a45..0f1b22b2 100644 --- a/src/executor/query.rs +++ b/src/executor/query.rs @@ -379,8 +379,9 @@ impl TryGetable for Decimal { use rust_decimal::prelude::FromPrimitive; match val { Some(v) => Decimal::from_f64(v).ok_or_else(|| { - TryGetError::DbErr(DbErr::Query( - "Failed to convert f64 into Decimal".to_owned(), + TryGetError::DbErr(DbErr::CannotConvertInto( + "f64".to_owned(), + "Decimal".to_owned(), )) }), None => Err(TryGetError::Null(column)), @@ -708,10 +709,7 @@ macro_rules! try_from_u64_err { ( $type: ty ) => { impl TryFromU64 for $type { fn try_from_u64(_: u64) -> Result { - Err(DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!($type) - ))) + Err(DbErr::ConvertFromU64(stringify!($type).to_string())) } } }; @@ -722,10 +720,7 @@ macro_rules! try_from_u64_err { $( $gen_type: TryFromU64, )* { fn try_from_u64(_: u64) -> Result { - Err(DbErr::Exec(format!( - "{} cannot be converted from u64", - stringify!(($($gen_type,)*)) - ))) + Err(DbErr::ConvertFromU64(stringify!($($gen_type,)*).to_string())) } } }; @@ -744,11 +739,7 @@ macro_rules! try_from_u64_numeric { fn try_from_u64(n: u64) -> Result { use std::convert::TryInto; n.try_into().map_err(|_| { - DbErr::Exec(format!( - "fail to convert '{}' into '{}'", - n, - stringify!($type) - )) + DbErr::CannotConvertInto(n.to_string(), stringify!($type).to_string()) }) } } @@ -828,9 +819,11 @@ mod tests { #[test] fn from_try_get_error() { // TryGetError::DbErr - let expected = DbErr::Query("expected error message".to_owned()); - let try_get_error = TryGetError::DbErr(expected.clone()); - assert_eq!(DbErr::from(try_get_error), expected); + let try_get_error = TryGetError::DbErr(DbErr::Query("expected error message".to_owned())); + assert_eq!( + DbErr::from(try_get_error), + DbErr::Query("expected error message".to_owned()) + ); // TryGetError::Null let try_get_error = TryGetError::Null("column".to_owned()); diff --git a/src/executor/update.rs b/src/executor/update.rs index f00c536f..cd9337f7 100644 --- a/src/executor/update.rs +++ b/src/executor/update.rs @@ -122,7 +122,7 @@ where Updater::new(query).check_record_exists().exec(db).await?; let primary_key_value = match model.get_primary_key_value() { Some(val) => FromValueTuple::from_value_tuple(val), - None => return Err(DbErr::Exec("Fail to get primary key from model".to_owned())), + None => return Err(DbErr::UpdateCouldNotGetPrimaryKey), }; let found = ::find_by_id(primary_key_value) .one(db) @@ -130,7 +130,9 @@ where // If we cannot select the updated row from db by the cached primary key match found { Some(model) => Ok(model), - None => Err(DbErr::Exec("Failed to find inserted item".to_owned())), + None => Err(DbErr::RecordNotFound( + "Failed to find inserted item".to_owned(), + )), } } } diff --git a/tests/crud/error.rs b/tests/crud/error.rs new file mode 100644 index 00000000..8dfab3fa --- /dev/null +++ b/tests/crud/error.rs @@ -0,0 +1,56 @@ +pub use super::*; +use rust_decimal_macros::dec; +use sea_orm::DbErr; +#[cfg(any( + feature = "sqlx-mysql", + feature = "sqlx-sqlite", + feature = "sqlx-postgres" +))] +use sqlx::Error; +use uuid::Uuid; + +pub async fn test_cake_error_sqlx(db: &DbConn) { + let mud_cake = cake::ActiveModel { + name: Set("Moldy Cake".to_owned()), + price: Set(dec!(10.25)), + gluten_free: Set(false), + serial: Set(Uuid::new_v4()), + bakery_id: Set(None), + ..Default::default() + }; + + let cake = mud_cake.save(db).await.expect("could not insert cake"); + + // if compiling without sqlx, this assignment will complain, + // but the whole test is useless in that case anyway. + #[allow(unused_variables)] + let error: DbErr = cake + .into_active_model() + .insert(db) + .await + .expect_err("inserting should fail due to duplicate primary key"); + + #[cfg(any(feature = "sqlx-mysql", feature = "sqlx-sqlite"))] + match error { + DbErr::ExecSqlX(sqlx_error) => match sqlx_error { + Error::Database(e) => { + #[cfg(feature = "sqlx-mysql")] + assert_eq!(e.code().unwrap(), "23000"); + #[cfg(feature = "sqlx-sqlite")] + assert_eq!(e.code().unwrap(), "1555"); + } + _ => panic!("Unexpected sqlx-error kind"), + }, + _ => panic!("Unexpected Error kind"), + } + #[cfg(feature = "sqlx-postgres")] + match error { + DbErr::QuerySqlX(sqlx_error) => match sqlx_error { + Error::Database(e) => { + assert_eq!(e.code().unwrap(), "23505"); + } + _ => panic!("Unexpected sqlx-error kind"), + }, + _ => panic!("Unexpected Error kind"), + } +} diff --git a/tests/crud/mod.rs b/tests/crud/mod.rs index 916878c8..3a629d7a 100644 --- a/tests/crud/mod.rs +++ b/tests/crud/mod.rs @@ -3,6 +3,7 @@ pub mod create_cake; pub mod create_lineitem; pub mod create_order; pub mod deletes; +pub mod error; pub mod updates; pub use create_baker::*; @@ -10,6 +11,7 @@ pub use create_cake::*; pub use create_lineitem::*; pub use create_order::*; pub use deletes::*; +pub use error::*; pub use updates::*; pub use super::common::bakery_chain::*; diff --git a/tests/crud_tests.rs b/tests/crud_tests.rs index bc2a3c97..9f7ea404 100644 --- a/tests/crud_tests.rs +++ b/tests/crud_tests.rs @@ -35,5 +35,6 @@ pub async fn create_entities(db: &DatabaseConnection) { test_update_deleted_customer(db).await; test_delete_cake(db).await; + test_cake_error_sqlx(db).await; test_delete_bakery(db).await; } From 0b754eab0b1b9a8fcbcefce48878e0340514edb2 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 28 Aug 2022 12:59:09 +0800 Subject: [PATCH 02/71] Refactor Type Errors --- issues/324/src/model.rs | 2 +- issues/400/src/model.rs | 2 +- src/error.rs | 21 ++++++++++++++------- src/executor/query.rs | 22 ++++++++++++---------- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/issues/324/src/model.rs b/issues/324/src/model.rs index 37174db9..940bc9ca 100644 --- a/issues/324/src/model.rs +++ b/issues/324/src/model.rs @@ -26,7 +26,7 @@ macro_rules! impl_try_from_u64_err { ($newtype: ident) => { impl sea_orm::TryFromU64 for $newtype { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::ConvertFromU64(stringify!($newtype))) + Err(sea_orm::CannotConvertFromU64(stringify!($newtype))) } } }; diff --git a/issues/400/src/model.rs b/issues/400/src/model.rs index 6d9044c4..25e4732f 100644 --- a/issues/400/src/model.rs +++ b/issues/400/src/model.rs @@ -31,7 +31,7 @@ impl From> for Uuid { impl sea_orm::TryFromU64 for AccountId { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::ConvertFromU64(stringify!(AccountId))) + Err(sea_orm::CannotConvertFromU64(stringify!(AccountId))) } } diff --git a/src/error.rs b/src/error.rs index 30b2909d..6d594095 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,15 +5,22 @@ use thiserror::Error; /// An error from unsuccessful database operations #[derive(Error, Debug)] pub enum DbErr { - /// This error happens, when a pool was not able to create a connection + /// This error can happen when the connection pool is fully-utilized #[error("Failed to acquire connection from pool.")] ConnFromPool, - /// Error in case of invalid type conversion attempts - #[error("fail to convert '{0}' into '{1}'")] - CannotConvertInto(String, String), - /// Error in case of invalid type conversion from an u64 - #[error("{0} cannot be converted from u64")] - ConvertFromU64(String), + /// Runtime type conversion error + #[error("Error converting `{from}` into `{into}`: {source}")] + TryIntoErr { + /// From type + from: &'static str, + /// Into type + into: &'static str, + /// TryError + source: Box, + }, + /// Type error: the specified type cannot be converted from u64. This is not a runtime error. + #[error("Type `{0}` cannot be converted from u64")] + CannotConvertFromU64(&'static str), /// After an insert statement it was impossible to retrieve the last_insert_id #[error("Fail to unpack last_insert_id")] InsertCouldNotUnpackInsertId, diff --git a/src/executor/query.rs b/src/executor/query.rs index 0f1b22b2..c2d0a051 100644 --- a/src/executor/query.rs +++ b/src/executor/query.rs @@ -376,13 +376,13 @@ impl TryGetable for Decimal { let val: Option = row .try_get(column.as_str()) .map_err(|e| TryGetError::DbErr(crate::sqlx_error_to_query_err(e)))?; - use rust_decimal::prelude::FromPrimitive; match val { - Some(v) => Decimal::from_f64(v).ok_or_else(|| { - TryGetError::DbErr(DbErr::CannotConvertInto( - "f64".to_owned(), - "Decimal".to_owned(), - )) + Some(v) => Decimal::try_from(v).map_err(|e| { + TryGetError::DbErr(DbErr::TryIntoErr { + from: "f64", + into: "Decimal", + source: Box::new(e), + }) }), None => Err(TryGetError::Null(column)), } @@ -709,7 +709,7 @@ macro_rules! try_from_u64_err { ( $type: ty ) => { impl TryFromU64 for $type { fn try_from_u64(_: u64) -> Result { - Err(DbErr::ConvertFromU64(stringify!($type).to_string())) + Err(DbErr::CannotConvertFromU64(stringify!($type))) } } }; @@ -720,7 +720,7 @@ macro_rules! try_from_u64_err { $( $gen_type: TryFromU64, )* { fn try_from_u64(_: u64) -> Result { - Err(DbErr::ConvertFromU64(stringify!($($gen_type,)*).to_string())) + Err(DbErr::CannotConvertFromU64(stringify!($($gen_type,)*))) } } }; @@ -738,8 +738,10 @@ macro_rules! try_from_u64_numeric { impl TryFromU64 for $type { fn try_from_u64(n: u64) -> Result { use std::convert::TryInto; - n.try_into().map_err(|_| { - DbErr::CannotConvertInto(n.to_string(), stringify!($type).to_string()) + n.try_into().map_err(|e| DbErr::TryIntoErr { + from: stringify!(u64), + into: stringify!($type), + source: Box::new(e), }) } } From 348e841139fcef289c8e60c3d23261e68e443df0 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 28 Aug 2022 13:30:51 +0800 Subject: [PATCH 03/71] Refactor ColumnFromStrErr --- sea-orm-macros/src/derives/column.rs | 15 +++++++++++++-- src/entity/column.rs | 2 +- src/error.rs | 20 ++++++++++---------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/sea-orm-macros/src/derives/column.rs b/sea-orm-macros/src/derives/column.rs index 42e1dfe1..c60a91d6 100644 --- a/sea-orm-macros/src/derives/column.rs +++ b/sea-orm-macros/src/derives/column.rs @@ -71,7 +71,7 @@ pub fn impl_default_as_str(ident: &Ident, data: &Data) -> syn::Result syn::Result { let data_enum = match data { Data::Enum(data_enum) => data_enum, @@ -91,6 +91,17 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result ) }); + let entity_name = data_enum + .variants + .first() + .map(|column| { + let column_iden = column.ident.clone(); + quote!( + #ident::#column_iden.entity_name().to_string() + ) + }) + .unwrap(); + Ok(quote!( #[automatically_derived] impl std::str::FromStr for #ident { @@ -99,7 +110,7 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result fn from_str(s: &str) -> std::result::Result { match s { #(#columns),*, - _ => Err(sea_orm::ColumnFromStrErr(format!("Failed to parse '{}' as `{}`", s, stringify!(#ident)))), + _ => Err(sea_orm::ColumnFromStrErr{ string: s.to_owned(), entity: #entity_name }), } } } diff --git a/src/entity/column.rs b/src/entity/column.rs index 8dbff6ea..6f946f51 100644 --- a/src/entity/column.rs +++ b/src/entity/column.rs @@ -521,7 +521,7 @@ mod tests { )); assert!(matches!( fruit::Column::from_str("does_not_exist"), - Err(crate::ColumnFromStrErr(_)) + Err(crate::ColumnFromStrErr { .. }) )); } diff --git a/src/error.rs b/src/error.rs index 6d594095..a5db227e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,7 +16,7 @@ pub enum DbErr { /// Into type into: &'static str, /// TryError - source: Box, + source: Box, }, /// Type error: the specified type cannot be converted from u64. This is not a runtime error. #[error("Type `{0}` cannot be converted from u64")] @@ -69,14 +69,14 @@ impl PartialEq for DbErr { } } -/// An error from a failed column operation when trying to convert the column to a string -#[derive(Debug, Clone)] -pub struct ColumnFromStrErr(pub String); +impl Eq for DbErr {} -impl std::error::Error for ColumnFromStrErr {} - -impl std::fmt::Display for ColumnFromStrErr { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.0.as_str()) - } +/// Error during `impl FromStr for Entity::Column` +#[derive(Error, Debug)] +#[error("Failed to match \"{string}\" as Column for `{entity}`")] +pub struct ColumnFromStrErr { + /// Source of error + pub string: String, + /// Entity this column belongs to + pub entity: String, } From 0ce0f495511c981fd09bfd84578bf6f97c45f32b Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 28 Aug 2022 13:51:21 +0800 Subject: [PATCH 04/71] Refactor SqlxError; --- src/database/db_connection.rs | 12 +++++++++--- src/database/mod.rs | 6 +++--- src/driver/sqlx_common.rs | 8 ++++---- src/driver/sqlx_mysql.rs | 2 +- src/driver/sqlx_postgres.rs | 2 +- src/driver/sqlx_sqlite.rs | 2 +- src/error.rs | 27 +++++++++++++++------------ src/executor/query.rs | 4 ++-- 8 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/database/db_connection.rs b/src/database/db_connection.rs index ac4fd7e6..3fa48ec0 100644 --- a/src/database/db_connection.rs +++ b/src/database/db_connection.rs @@ -116,7 +116,9 @@ impl ConnectionTrait for DatabaseConnection { DatabaseConnection::SqlxSqlitePoolConnection(conn) => conn.execute(stmt).await, #[cfg(feature = "mock")] DatabaseConnection::MockDatabaseConnection(conn) => conn.execute(stmt), - DatabaseConnection::Disconnected => Err(DbErr::Conn("Disconnected".to_owned())), + DatabaseConnection::Disconnected => { + Err(DbErr::Conn(RuntimeErr::Internal("Disconnected".to_owned()))) + } } } @@ -132,7 +134,9 @@ impl ConnectionTrait for DatabaseConnection { DatabaseConnection::SqlxSqlitePoolConnection(conn) => conn.query_one(stmt).await, #[cfg(feature = "mock")] DatabaseConnection::MockDatabaseConnection(conn) => conn.query_one(stmt), - DatabaseConnection::Disconnected => Err(DbErr::Conn("Disconnected".to_owned())), + DatabaseConnection::Disconnected => { + Err(DbErr::Conn(RuntimeErr::Internal("Disconnected".to_owned()))) + } } } @@ -148,7 +152,9 @@ impl ConnectionTrait for DatabaseConnection { DatabaseConnection::SqlxSqlitePoolConnection(conn) => conn.query_all(stmt).await, #[cfg(feature = "mock")] DatabaseConnection::MockDatabaseConnection(conn) => conn.query_all(stmt), - DatabaseConnection::Disconnected => Err(DbErr::Conn("Disconnected".to_owned())), + DatabaseConnection::Disconnected => { + Err(DbErr::Conn(RuntimeErr::Internal("Disconnected".to_owned()))) + } } } diff --git a/src/database/mod.rs b/src/database/mod.rs index 4a24ec88..22a173c5 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -18,7 +18,7 @@ pub use stream::*; use tracing::instrument; pub use transaction::*; -use crate::DbErr; +use crate::{DbErr, RuntimeErr}; /// Defines a database #[derive(Debug, Default)] @@ -73,10 +73,10 @@ impl Database { if crate::MockDatabaseConnector::accepts(&opt.url) { return crate::MockDatabaseConnector::connect(&opt.url).await; } - Err(DbErr::Conn(format!( + Err(DbErr::Conn(RuntimeErr::Internal(format!( "The connection string '{}' has no supporting driver.", opt.url - ))) + )))) } } diff --git a/src/driver/sqlx_common.rs b/src/driver/sqlx_common.rs index 91509813..37a250ed 100644 --- a/src/driver/sqlx_common.rs +++ b/src/driver/sqlx_common.rs @@ -1,16 +1,16 @@ -use crate::DbErr; +use crate::{DbErr, RuntimeErr}; /// Converts an [sqlx::error] execution error to a [DbErr] pub fn sqlx_error_to_exec_err(err: sqlx::Error) -> DbErr { - DbErr::ExecSqlX(err) + DbErr::Exec(RuntimeErr::SqlxError(err)) } /// Converts an [sqlx::error] query error to a [DbErr] pub fn sqlx_error_to_query_err(err: sqlx::Error) -> DbErr { - DbErr::QuerySqlX(err) + DbErr::Query(RuntimeErr::SqlxError(err)) } /// Converts an [sqlx::error] connection error to a [DbErr] pub fn sqlx_error_to_conn_err(err: sqlx::Error) -> DbErr { - DbErr::ConnSqlX(err) + DbErr::Conn(RuntimeErr::SqlxError(err)) } diff --git a/src/driver/sqlx_mysql.rs b/src/driver/sqlx_mysql.rs index e1f1d416..c15197a4 100644 --- a/src/driver/sqlx_mysql.rs +++ b/src/driver/sqlx_mysql.rs @@ -45,7 +45,7 @@ impl SqlxMySqlConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::ConnSqlX(e))?; + .map_err(sqlx_error_to_conn_err)?; use sqlx::ConnectOptions; if !options.sqlx_logging { opt.disable_statement_logging(); diff --git a/src/driver/sqlx_postgres.rs b/src/driver/sqlx_postgres.rs index 6d070115..78cc4f3c 100644 --- a/src/driver/sqlx_postgres.rs +++ b/src/driver/sqlx_postgres.rs @@ -45,7 +45,7 @@ impl SqlxPostgresConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::ConnSqlX(e))?; + .map_err(sqlx_error_to_conn_err)?; use sqlx::ConnectOptions; if !options.sqlx_logging { opt.disable_statement_logging(); diff --git a/src/driver/sqlx_sqlite.rs b/src/driver/sqlx_sqlite.rs index 55e768b0..f2b17a95 100644 --- a/src/driver/sqlx_sqlite.rs +++ b/src/driver/sqlx_sqlite.rs @@ -46,7 +46,7 @@ impl SqlxSqliteConnector { let mut opt = options .url .parse::() - .map_err(|e| DbErr::ConnSqlX(e))?; + .map_err(sqlx_error_to_conn_err)?; if options.sqlcipher_key.is_some() { opt = opt.pragma("key", options.sqlcipher_key.clone().unwrap()); } diff --git a/src/error.rs b/src/error.rs index a5db227e..26bd5ddc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,22 +30,13 @@ pub enum DbErr { UpdateCouldNotGetPrimaryKey, /// There was a problem with the database connection #[error("Connection Error: {0}")] - Conn(String), - /// There was a problem with the database connection from sqlx - #[cfg(feature = "sqlx-dep")] - #[error("Connection Error: {0}")] - ConnSqlX(#[source] SqlxError), + Conn(#[source] RuntimeErr), /// An operation did not execute successfully - #[cfg(feature = "sqlx-dep")] #[error("Execution Error: {0}")] - ExecSqlX(#[source] SqlxError), - /// An error occurred while performing a query, with more details from sqlx - #[cfg(feature = "sqlx-dep")] - #[error("Query Error: {0}")] - QuerySqlX(#[source] SqlxError), + Exec(#[source] RuntimeErr), /// An error occurred while performing a query #[error("Query Error: {0}")] - Query(String), + Query(#[source] RuntimeErr), /// The record was not found in the database #[error("RecordNotFound Error: {0}")] RecordNotFound(String), @@ -63,6 +54,18 @@ pub enum DbErr { Migration(String), } +/// Runtime error +#[derive(Error, Debug)] +pub enum RuntimeErr { + #[cfg(feature = "sqlx-dep")] + /// SQLx Error + #[error("{0}")] + SqlxError(SqlxError), + #[error("{0}")] + /// Error generated from within SeaORM + Internal(String), +} + impl PartialEq for DbErr { fn eq(&self, other: &Self) -> bool { self.to_string() == other.to_string() diff --git a/src/executor/query.rs b/src/executor/query.rs index c2d0a051..f27b1a2d 100644 --- a/src/executor/query.rs +++ b/src/executor/query.rs @@ -41,7 +41,7 @@ impl From for DbErr { match e { TryGetError::DbErr(e) => e, TryGetError::Null(s) => { - DbErr::Query(format!("error occurred while decoding {}: Null", s)) + DbErr::Type(format!("A null value was encountered while decoding {}", s)) } } } @@ -627,7 +627,7 @@ where fn try_get_many_with_slice_len_of(len: usize, cols: &[String]) -> Result<(), TryGetError> { if cols.len() < len { - Err(TryGetError::DbErr(DbErr::Query(format!( + Err(TryGetError::DbErr(DbErr::Type(format!( "Expect {} column names supplied but got slice of length {}", len, cols.len() From 85533a3bb3f9cfdab92212ce6c8d3ad1ac468dd3 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 28 Aug 2022 14:24:24 +0800 Subject: [PATCH 05/71] Give up and fix tests --- sea-orm-macros/src/derives/column.rs | 13 +------------ src/database/mock.rs | 10 +++++++--- src/entity/column.rs | 2 +- src/error.rs | 9 ++------- src/executor/query.rs | 12 +++++++----- tests/crud/error.rs | 6 +++--- tests/transaction_tests.rs | 12 +++++++++--- 7 files changed, 30 insertions(+), 34 deletions(-) diff --git a/sea-orm-macros/src/derives/column.rs b/sea-orm-macros/src/derives/column.rs index c60a91d6..3a0f9314 100644 --- a/sea-orm-macros/src/derives/column.rs +++ b/sea-orm-macros/src/derives/column.rs @@ -91,17 +91,6 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result ) }); - let entity_name = data_enum - .variants - .first() - .map(|column| { - let column_iden = column.ident.clone(); - quote!( - #ident::#column_iden.entity_name().to_string() - ) - }) - .unwrap(); - Ok(quote!( #[automatically_derived] impl std::str::FromStr for #ident { @@ -110,7 +99,7 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result fn from_str(s: &str) -> std::result::Result { match s { #(#columns),*, - _ => Err(sea_orm::ColumnFromStrErr{ string: s.to_owned(), entity: #entity_name }), + _ => Err(sea_orm::ColumnFromStrErr(s.to_owned())), } } } diff --git a/src/database/mock.rs b/src/database/mock.rs index a1dbc795..2c5b8d38 100644 --- a/src/database/mock.rs +++ b/src/database/mock.rs @@ -102,7 +102,9 @@ impl MockDatabaseTrait for MockDatabase { result: ExecResultHolder::Mock(std::mem::take(&mut self.exec_results[counter])), }) } else { - Err(DbErr::Query("`exec_results` buffer is empty.".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "`exec_results` buffer is empty.".to_owned(), + ))) } } @@ -121,7 +123,9 @@ impl MockDatabaseTrait for MockDatabase { }) .collect()) } else { - Err(DbErr::Query("`query_results` buffer is empty.".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "`query_results` buffer is empty.".to_owned(), + ))) } } @@ -176,7 +180,7 @@ impl MockRow { where T: ValueType, { - T::try_from(self.values.get(col).unwrap().clone()).map_err(|e| DbErr::Query(e.to_string())) + T::try_from(self.values.get(col).unwrap().clone()).map_err(|e| DbErr::Type(e.to_string())) } /// An iterator over the keys and values of a mock row diff --git a/src/entity/column.rs b/src/entity/column.rs index 6f946f51..8dbff6ea 100644 --- a/src/entity/column.rs +++ b/src/entity/column.rs @@ -521,7 +521,7 @@ mod tests { )); assert!(matches!( fruit::Column::from_str("does_not_exist"), - Err(crate::ColumnFromStrErr { .. }) + Err(crate::ColumnFromStrErr(_)) )); } diff --git a/src/error.rs b/src/error.rs index 26bd5ddc..34a828fb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -76,10 +76,5 @@ impl Eq for DbErr {} /// Error during `impl FromStr for Entity::Column` #[derive(Error, Debug)] -#[error("Failed to match \"{string}\" as Column for `{entity}`")] -pub struct ColumnFromStrErr { - /// Source of error - pub string: String, - /// Entity this column belongs to - pub entity: String, -} +#[error("Failed to match \"{0}\" as Column")] +pub struct ColumnFromStrErr(pub String); diff --git a/src/executor/query.rs b/src/executor/query.rs index f27b1a2d..90697fb3 100644 --- a/src/executor/query.rs +++ b/src/executor/query.rs @@ -816,20 +816,22 @@ try_from_u64_err!(uuid::Uuid); #[cfg(test)] mod tests { use super::TryGetError; - use crate::error::DbErr; + use crate::error::*; #[test] fn from_try_get_error() { // TryGetError::DbErr - let try_get_error = TryGetError::DbErr(DbErr::Query("expected error message".to_owned())); + let try_get_error = TryGetError::DbErr(DbErr::Query(RuntimeErr::Internal( + "expected error message".to_owned(), + ))); assert_eq!( DbErr::from(try_get_error), - DbErr::Query("expected error message".to_owned()) + DbErr::Query(RuntimeErr::Internal("expected error message".to_owned())) ); // TryGetError::Null let try_get_error = TryGetError::Null("column".to_owned()); - let expected = "error occurred while decoding column: Null".to_owned(); - assert_eq!(DbErr::from(try_get_error), DbErr::Query(expected)); + let expected = "A null value was encountered while decoding column".to_owned(); + assert_eq!(DbErr::from(try_get_error), DbErr::Type(expected)); } } diff --git a/tests/crud/error.rs b/tests/crud/error.rs index 8dfab3fa..4828d9cd 100644 --- a/tests/crud/error.rs +++ b/tests/crud/error.rs @@ -1,6 +1,6 @@ pub use super::*; use rust_decimal_macros::dec; -use sea_orm::DbErr; +use sea_orm::error::*; #[cfg(any( feature = "sqlx-mysql", feature = "sqlx-sqlite", @@ -32,7 +32,7 @@ pub async fn test_cake_error_sqlx(db: &DbConn) { #[cfg(any(feature = "sqlx-mysql", feature = "sqlx-sqlite"))] match error { - DbErr::ExecSqlX(sqlx_error) => match sqlx_error { + DbErr::Exec(RuntimeErr::SqlxError(error)) => match error { Error::Database(e) => { #[cfg(feature = "sqlx-mysql")] assert_eq!(e.code().unwrap(), "23000"); @@ -45,7 +45,7 @@ pub async fn test_cake_error_sqlx(db: &DbConn) { } #[cfg(feature = "sqlx-postgres")] match error { - DbErr::QuerySqlX(sqlx_error) => match sqlx_error { + DbErr::Query(RuntimeErr::SqlxError(error)) => match error { Error::Database(e) => { assert_eq!(e.code().unwrap(), "23505"); } diff --git a/tests/transaction_tests.rs b/tests/transaction_tests.rs index 1059da8e..9faab72a 100644 --- a/tests/transaction_tests.rs +++ b/tests/transaction_tests.rs @@ -508,7 +508,9 @@ pub async fn transaction_nested() { assert_eq!(bakeries.len(), 4); if true { - Err(DbErr::Query("Force Rollback!".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "Force Rollback!".to_owned(), + ))) } else { Ok(()) } @@ -633,7 +635,9 @@ pub async fn transaction_nested() { assert_eq!(bakeries.len(), 7); if true { - Err(DbErr::Query("Force Rollback!".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "Force Rollback!".to_owned(), + ))) } else { Ok(()) } @@ -652,7 +656,9 @@ pub async fn transaction_nested() { assert_eq!(bakeries.len(), 6); if true { - Err(DbErr::Query("Force Rollback!".to_owned())) + Err(DbErr::Query(RuntimeErr::Internal( + "Force Rollback!".to_owned(), + ))) } else { Ok(()) } From 0efdfc6742dd49b9b26d72a3102ea2df99a2454f Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 1 Sep 2022 15:57:58 +0800 Subject: [PATCH 06/71] Typo --- src/database/mock.rs | 2 +- src/executor/update.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/database/mock.rs b/src/database/mock.rs index 2c5b8d38..1677938f 100644 --- a/src/database/mock.rs +++ b/src/database/mock.rs @@ -102,7 +102,7 @@ impl MockDatabaseTrait for MockDatabase { result: ExecResultHolder::Mock(std::mem::take(&mut self.exec_results[counter])), }) } else { - Err(DbErr::Query(RuntimeErr::Internal( + Err(DbErr::Exec(RuntimeErr::Internal( "`exec_results` buffer is empty.".to_owned(), ))) } diff --git a/src/executor/update.rs b/src/executor/update.rs index cd9337f7..1edd9461 100644 --- a/src/executor/update.rs +++ b/src/executor/update.rs @@ -131,7 +131,7 @@ where match found { Some(model) => Ok(model), None => Err(DbErr::RecordNotFound( - "Failed to find inserted item".to_owned(), + "Failed to find updated item".to_owned(), )), } } From 4e835f2ee1e962e3c5a5d737a5919bca2b9e42da Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 1 Sep 2022 16:38:47 +0800 Subject: [PATCH 07/71] . --- sea-orm-macros/src/derives/column.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/sea-orm-macros/src/derives/column.rs b/sea-orm-macros/src/derives/column.rs index 3a0f9314..6d4e212b 100644 --- a/sea-orm-macros/src/derives/column.rs +++ b/sea-orm-macros/src/derives/column.rs @@ -90,6 +90,7 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result #column_str_snake | #column_str_mixed => Ok(#ident::#column_iden) ) }); + Ok(quote!( #[automatically_derived] From 6564ddac15488c990609fa16689f8813d1b280fa Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 1 Sep 2022 16:39:18 +0800 Subject: [PATCH 08/71] Testing [issues] [cli] --- sea-orm-macros/src/derives/column.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/sea-orm-macros/src/derives/column.rs b/sea-orm-macros/src/derives/column.rs index 6d4e212b..3a0f9314 100644 --- a/sea-orm-macros/src/derives/column.rs +++ b/sea-orm-macros/src/derives/column.rs @@ -90,7 +90,6 @@ pub fn impl_col_from_str(ident: &Ident, data: &Data) -> syn::Result #column_str_snake | #column_str_mixed => Ok(#ident::#column_iden) ) }); - Ok(quote!( #[automatically_derived] From 5f3210c62a52822ee1e88898b63c8cb48cbf16b7 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 1 Sep 2022 16:52:03 +0800 Subject: [PATCH 09/71] Fix [issues] --- issues/324/src/model.rs | 2 +- issues/400/src/model.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/324/src/model.rs b/issues/324/src/model.rs index 940bc9ca..1a2720be 100644 --- a/issues/324/src/model.rs +++ b/issues/324/src/model.rs @@ -26,7 +26,7 @@ macro_rules! impl_try_from_u64_err { ($newtype: ident) => { impl sea_orm::TryFromU64 for $newtype { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::CannotConvertFromU64(stringify!($newtype))) + Err(sea_orm::DbErr::CannotConvertFromU64(stringify!($newtype))) } } }; diff --git a/issues/400/src/model.rs b/issues/400/src/model.rs index 25e4732f..134397ac 100644 --- a/issues/400/src/model.rs +++ b/issues/400/src/model.rs @@ -31,7 +31,7 @@ impl From> for Uuid { impl sea_orm::TryFromU64 for AccountId { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::CannotConvertFromU64(stringify!(AccountId))) + Err(sea_orm::DbErr::CannotConvertFromU64(stringify!(AccountId))) } } From 1c8ef335440357c3131879fed94c9f1d5b2098e2 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 3 Sep 2022 20:44:45 +0800 Subject: [PATCH 10/71] Update src/error.rs Co-authored-by: Sculas --- src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 34a828fb..f4851d92 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,7 +22,7 @@ pub enum DbErr { #[error("Type `{0}` cannot be converted from u64")] CannotConvertFromU64(&'static str), /// After an insert statement it was impossible to retrieve the last_insert_id - #[error("Fail to unpack last_insert_id")] + #[error("Failed to unpack last_insert_id")] InsertCouldNotUnpackInsertId, /// When updating, a model should know it's primary key to check /// if the record has been correctly updated, otherwise this error will occur From 4d95218430e7251ba51d3a071f53d0a69d93d971 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 3 Sep 2022 20:44:59 +0800 Subject: [PATCH 11/71] Update src/error.rs Co-authored-by: Sculas --- src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index f4851d92..02920737 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,7 +26,7 @@ pub enum DbErr { InsertCouldNotUnpackInsertId, /// When updating, a model should know it's primary key to check /// if the record has been correctly updated, otherwise this error will occur - #[error("Fail to get primary key from model")] + #[error("Failed to get primary key from model")] UpdateCouldNotGetPrimaryKey, /// There was a problem with the database connection #[error("Connection Error: {0}")] From 4993c6ab2df095494e19f8f5379e25178b201161 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 3 Sep 2022 20:45:09 +0800 Subject: [PATCH 12/71] Update src/error.rs Co-authored-by: Sculas --- src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 02920737..813f4545 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,7 +6,7 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum DbErr { /// This error can happen when the connection pool is fully-utilized - #[error("Failed to acquire connection from pool.")] + #[error("Failed to acquire connection from pool")] ConnFromPool, /// Runtime type conversion error #[error("Error converting `{from}` into `{into}`: {source}")] From 36f09c524e1886b869405cb333eb41d68785be58 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 3 Sep 2022 20:45:17 +0800 Subject: [PATCH 13/71] Update src/error.rs Co-authored-by: Sculas --- src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 813f4545..3f2e5375 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,7 +19,7 @@ pub enum DbErr { source: Box, }, /// Type error: the specified type cannot be converted from u64. This is not a runtime error. - #[error("Type `{0}` cannot be converted from u64")] + #[error("Type '{0}' cannot be converted from u64")] CannotConvertFromU64(&'static str), /// After an insert statement it was impossible to retrieve the last_insert_id #[error("Failed to unpack last_insert_id")] From 2f4c4812803bc5c5c79259ff2f7edf32e2df52c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= <7492268+zoedberg@users.noreply.github.com> Date: Tue, 13 Sep 2022 05:58:13 +0200 Subject: [PATCH 14/71] Community.md (#1027) --- COMMUNITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/COMMUNITY.md b/COMMUNITY.md index 347d1e51..43d66c50 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -55,6 +55,7 @@ If you have built an app using SeaORM and want to showcase it, feel free to open - [nitro_repo](https://github.com/wyatt-herkamp/nitro_repo) | An OpenSource, lightweight, and fast artifact manager. - [MoonRamp](https://github.com/MoonRamp/MoonRamp) | A free and open source crypto payment gateway - [url_shortener](https://github.com/michidk/url_shortener) | A simple self-hosted URL shortener written in Rust +- [RGB Lib](https://github.com/RGB-Tools/rgb-lib) | A library to manage wallets for RGB assets ## Learning Resources From 122525543167c6cb544a537cb7c4dc0b9abafa2f Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Wed, 14 Sep 2022 00:28:24 +0800 Subject: [PATCH 15/71] Address comments --- issues/324/src/model.rs | 2 +- issues/400/src/model.rs | 2 +- src/driver/sqlx_mysql.rs | 12 ++++++------ src/driver/sqlx_postgres.rs | 12 ++++++------ src/driver/sqlx_sqlite.rs | 12 ++++++------ src/error.rs | 12 ++++++------ src/executor/insert.rs | 2 +- src/executor/query.rs | 4 ++-- src/executor/update.rs | 2 +- 9 files changed, 30 insertions(+), 30 deletions(-) diff --git a/issues/324/src/model.rs b/issues/324/src/model.rs index 1a2720be..8b335c25 100644 --- a/issues/324/src/model.rs +++ b/issues/324/src/model.rs @@ -26,7 +26,7 @@ macro_rules! impl_try_from_u64_err { ($newtype: ident) => { impl sea_orm::TryFromU64 for $newtype { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::DbErr::CannotConvertFromU64(stringify!($newtype))) + Err(sea_orm::DbErr::ConvertFromU64(stringify!($newtype))) } } }; diff --git a/issues/400/src/model.rs b/issues/400/src/model.rs index 134397ac..28a44d64 100644 --- a/issues/400/src/model.rs +++ b/issues/400/src/model.rs @@ -31,7 +31,7 @@ impl From> for Uuid { impl sea_orm::TryFromU64 for AccountId { fn try_from_u64(_n: u64) -> Result { - Err(sea_orm::DbErr::CannotConvertFromU64(stringify!(AccountId))) + Err(sea_orm::DbErr::ConvertFromU64(stringify!(AccountId))) } } diff --git a/src/driver/sqlx_mysql.rs b/src/driver/sqlx_mysql.rs index c15197a4..e3d0ddbf 100644 --- a/src/driver/sqlx_mysql.rs +++ b/src/driver/sqlx_mysql.rs @@ -89,7 +89,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -110,7 +110,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -128,7 +128,7 @@ impl SqlxMySqlPoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -144,7 +144,7 @@ impl SqlxMySqlPoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -154,7 +154,7 @@ impl SqlxMySqlPoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_mysql(conn, self.metric_callback.clone()).await } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -175,7 +175,7 @@ impl SqlxMySqlPoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::ConnFromPool)) + Err(TransactionError::Connection(DbErr::ConnectionAcquire)) } } diff --git a/src/driver/sqlx_postgres.rs b/src/driver/sqlx_postgres.rs index 78cc4f3c..085abaa8 100644 --- a/src/driver/sqlx_postgres.rs +++ b/src/driver/sqlx_postgres.rs @@ -89,7 +89,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -110,7 +110,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -128,7 +128,7 @@ impl SqlxPostgresPoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -144,7 +144,7 @@ impl SqlxPostgresPoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -154,7 +154,7 @@ impl SqlxPostgresPoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_postgres(conn, self.metric_callback.clone()).await } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -175,7 +175,7 @@ impl SqlxPostgresPoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::ConnFromPool)) + Err(TransactionError::Connection(DbErr::ConnectionAcquire)) } } diff --git a/src/driver/sqlx_sqlite.rs b/src/driver/sqlx_sqlite.rs index f2b17a95..36e9dfa4 100644 --- a/src/driver/sqlx_sqlite.rs +++ b/src/driver/sqlx_sqlite.rs @@ -96,7 +96,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -117,7 +117,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -135,7 +135,7 @@ impl SqlxSqlitePoolConnection { } }) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -151,7 +151,7 @@ impl SqlxSqlitePoolConnection { self.metric_callback.clone(), ))) } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -161,7 +161,7 @@ impl SqlxSqlitePoolConnection { if let Ok(conn) = self.pool.acquire().await { DatabaseTransaction::new_sqlite(conn, self.metric_callback.clone()).await } else { - Err(DbErr::ConnFromPool) + Err(DbErr::ConnectionAcquire) } } @@ -182,7 +182,7 @@ impl SqlxSqlitePoolConnection { .map_err(|e| TransactionError::Connection(e))?; transaction.run(callback).await } else { - Err(TransactionError::Connection(DbErr::ConnFromPool)) + Err(TransactionError::Connection(DbErr::ConnectionAcquire)) } } diff --git a/src/error.rs b/src/error.rs index 3f2e5375..e45b3834 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,7 +7,7 @@ use thiserror::Error; pub enum DbErr { /// This error can happen when the connection pool is fully-utilized #[error("Failed to acquire connection from pool")] - ConnFromPool, + ConnectionAcquire, /// Runtime type conversion error #[error("Error converting `{from}` into `{into}`: {source}")] TryIntoErr { @@ -20,14 +20,14 @@ pub enum DbErr { }, /// Type error: the specified type cannot be converted from u64. This is not a runtime error. #[error("Type '{0}' cannot be converted from u64")] - CannotConvertFromU64(&'static str), + ConvertFromU64(&'static str), /// After an insert statement it was impossible to retrieve the last_insert_id #[error("Failed to unpack last_insert_id")] - InsertCouldNotUnpackInsertId, + UnpackInsertId, /// When updating, a model should know it's primary key to check /// if the record has been correctly updated, otherwise this error will occur #[error("Failed to get primary key from model")] - UpdateCouldNotGetPrimaryKey, + UpdateGetPrimeryKey, /// There was a problem with the database connection #[error("Connection Error: {0}")] Conn(#[source] RuntimeErr), @@ -57,12 +57,12 @@ pub enum DbErr { /// Runtime error #[derive(Error, Debug)] pub enum RuntimeErr { - #[cfg(feature = "sqlx-dep")] /// SQLx Error + #[cfg(feature = "sqlx-dep")] #[error("{0}")] SqlxError(SqlxError), - #[error("{0}")] /// Error generated from within SeaORM + #[error("{0}")] Internal(String), } diff --git a/src/executor/insert.rs b/src/executor/insert.rs index f7bb0629..33d0d4ed 100644 --- a/src/executor/insert.rs +++ b/src/executor/insert.rs @@ -130,7 +130,7 @@ where Some(value_tuple) => FromValueTuple::from_value_tuple(value_tuple), None => match last_insert_id_opt { Some(last_insert_id) => last_insert_id, - None => return Err(DbErr::InsertCouldNotUnpackInsertId), + None => return Err(DbErr::UnpackInsertId), }, }; Ok(InsertResult { last_insert_id }) diff --git a/src/executor/query.rs b/src/executor/query.rs index 90697fb3..3b53542b 100644 --- a/src/executor/query.rs +++ b/src/executor/query.rs @@ -709,7 +709,7 @@ macro_rules! try_from_u64_err { ( $type: ty ) => { impl TryFromU64 for $type { fn try_from_u64(_: u64) -> Result { - Err(DbErr::CannotConvertFromU64(stringify!($type))) + Err(DbErr::ConvertFromU64(stringify!($type))) } } }; @@ -720,7 +720,7 @@ macro_rules! try_from_u64_err { $( $gen_type: TryFromU64, )* { fn try_from_u64(_: u64) -> Result { - Err(DbErr::CannotConvertFromU64(stringify!($($gen_type,)*))) + Err(DbErr::ConvertFromU64(stringify!($($gen_type,)*))) } } }; diff --git a/src/executor/update.rs b/src/executor/update.rs index 1edd9461..7fa8b242 100644 --- a/src/executor/update.rs +++ b/src/executor/update.rs @@ -122,7 +122,7 @@ where Updater::new(query).check_record_exists().exec(db).await?; let primary_key_value = match model.get_primary_key_value() { Some(val) => FromValueTuple::from_value_tuple(val), - None => return Err(DbErr::UpdateCouldNotGetPrimaryKey), + None => return Err(DbErr::UpdateGetPrimeryKey), }; let found = ::find_by_id(primary_key_value) .one(db) From bde43f51f8f47bc6236104e0c57903b0181ac351 Mon Sep 17 00:00:00 2001 From: Jimmy Cuadra Date: Wed, 14 Sep 2022 22:51:05 -0700 Subject: [PATCH 16/71] Implement `IntoActiveValue` for `time` types. I tried to implement a [custom active model](https://www.sea-ql.org/SeaORM/docs/advanced-query/custom-active-model/), and one of the columns was `Option`. I got a compiler error: ``` error[E0277]: the trait bound `std::option::Option: IntoActiveValue<_>` is not satisfied ``` Looking into the source code, it seemed a simple oversight that this trait was implemented for the `chrono` types but not the `time` types, and it was easy enough to fix since there's already a macro to implement it for new types. I also noticed that the `time` types are not accounted for in `src/query/json.rs` while the `chrono` types are, which I assume is also an oversight. However, I don't have a need for that at this point and the fix for that seemed less trivial, so I'm just bringing it to your attention. Thanks for SeaORM! --- src/entity/active_model.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index afc8bd28..cf679d96 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -719,6 +719,22 @@ impl_into_active_value!(crate::prelude::Decimal); #[cfg_attr(docsrs, doc(cfg(feature = "with-uuid")))] impl_into_active_value!(crate::prelude::Uuid); +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeDate); + +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeTime); + +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeDateTime); + +#[cfg(feature = "with-time")] +#[cfg_attr(docsrs, doc(cfg(feature = "with-time")))] +impl_into_active_value!(crate::prelude::TimeDateTimeWithTimeZone); + impl Default for ActiveValue where V: Into, From 8ecb4fccba3bc7eca807899996071069fccf2363 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 15 Sep 2022 15:40:34 +0800 Subject: [PATCH 17/71] Implement `IntoActiveValue` for `Vec` types --- src/entity/active_model.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index cf679d96..0603662c 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -682,6 +682,7 @@ impl_into_active_value!(f32); impl_into_active_value!(f64); impl_into_active_value!(&'static str); impl_into_active_value!(String); +impl_into_active_value!(Vec); #[cfg(feature = "with-json")] #[cfg_attr(docsrs, doc(cfg(feature = "with-json")))] From 0d31a012cc94519efe08b2335f4e9e5a749020bb Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 15 Sep 2022 15:41:13 +0800 Subject: [PATCH 18/71] Add tests to double check and prevent it from happening again --- tests/type_tests.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/type_tests.rs diff --git a/tests/type_tests.rs b/tests/type_tests.rs new file mode 100644 index 00000000..5ceaf511 --- /dev/null +++ b/tests/type_tests.rs @@ -0,0 +1,52 @@ +pub mod common; + +use sea_orm::{IntoActiveValue, TryFromU64, TryGetable, Value}; + +pub fn it_impl_into_active_value, V: Into>() {} + +pub fn it_impl_try_getable() {} + +pub fn it_impl_try_from_u64() {} + +macro_rules! it_impl_traits { + ( $ty: ty ) => { + it_impl_into_active_value::<$ty, $ty>(); + it_impl_into_active_value::, Option<$ty>>(); + it_impl_into_active_value::>, Option<$ty>>(); + + it_impl_try_getable::<$ty>(); + it_impl_try_getable::>(); + + it_impl_try_from_u64::<$ty>(); + }; +} + +#[sea_orm_macros::test] +fn main() { + it_impl_traits!(i8); + it_impl_traits!(i16); + it_impl_traits!(i32); + it_impl_traits!(i64); + it_impl_traits!(u8); + it_impl_traits!(u16); + it_impl_traits!(u32); + it_impl_traits!(u64); + it_impl_traits!(bool); + it_impl_traits!(f32); + it_impl_traits!(f64); + it_impl_traits!(Vec); + it_impl_traits!(String); + it_impl_traits!(serde_json::Value); + it_impl_traits!(chrono::NaiveDate); + it_impl_traits!(chrono::NaiveTime); + it_impl_traits!(chrono::NaiveDateTime); + it_impl_traits!(chrono::DateTime); + it_impl_traits!(chrono::DateTime); + it_impl_traits!(chrono::DateTime); + it_impl_traits!(time::Date); + it_impl_traits!(time::Time); + it_impl_traits!(time::PrimitiveDateTime); + it_impl_traits!(time::OffsetDateTime); + it_impl_traits!(rust_decimal::Decimal); + it_impl_traits!(uuid::Uuid); +} From 7c591049bee99b0142ff307c9d863acbed4f3d65 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 15 Sep 2022 16:18:13 +0800 Subject: [PATCH 19/71] Add docs --- tests/type_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/type_tests.rs b/tests/type_tests.rs index 5ceaf511..4225b080 100644 --- a/tests/type_tests.rs +++ b/tests/type_tests.rs @@ -2,6 +2,18 @@ pub mod common; use sea_orm::{IntoActiveValue, TryFromU64, TryGetable, Value}; +/* + +When supporting a new type in SeaORM we should implement the following traits for it: + - `IntoActiveValue`, given that it implemented `Into` already + - `TryGetable` + - `TryFromU64` + +Also, we need to update `impl FromQueryResult for JsonValue` at `src/query/json.rs` +to correctly serialize the type as `serde_json::Value`. + +*/ + pub fn it_impl_into_active_value, V: Into>() {} pub fn it_impl_try_getable() {} From 1035bbb431efbb12b696f12fdc58ccbb802a0268 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 15 Sep 2022 16:44:31 +0800 Subject: [PATCH 20/71] Fixup --- tests/type_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/type_tests.rs b/tests/type_tests.rs index 4225b080..0c936130 100644 --- a/tests/type_tests.rs +++ b/tests/type_tests.rs @@ -20,6 +20,7 @@ pub fn it_impl_try_getable() {} pub fn it_impl_try_from_u64() {} +#[allow(unused_macros)] macro_rules! it_impl_traits { ( $ty: ty ) => { it_impl_into_active_value::<$ty, $ty>(); @@ -34,6 +35,7 @@ macro_rules! it_impl_traits { } #[sea_orm_macros::test] +#[cfg(feature = "sqlx-dep")] fn main() { it_impl_traits!(i8); it_impl_traits!(i16); From be0d846d8fae441d4bf7e216df829f75b90f2145 Mon Sep 17 00:00:00 2001 From: Remo Senekowitsch Date: Mon, 19 Sep 2022 17:42:46 +0200 Subject: [PATCH 21/71] Enable migration generation in modules (#933) * Enable migration generation in modules Previously, migration generation expected migrations to be at the crate root. * Fix migration backup file extension * Document behavior of migration_dir --- sea-orm-cli/src/cli.rs | 6 +++- sea-orm-cli/src/commands/migrate.rs | 56 +++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/sea-orm-cli/src/cli.rs b/sea-orm-cli/src/cli.rs index 68f32ef8..107f0f0c 100644 --- a/sea-orm-cli/src/cli.rs +++ b/sea-orm-cli/src/cli.rs @@ -25,7 +25,11 @@ pub enum Commands { global = true, short = 'd', long, - help = "Migration script directory", + help = "Migration script directory. +If your migrations are in their own crate, +you can provide the root of that crate. +If your migrations are in a submodule of your app, +you should provide the directory of that submodule.", default_value = "./migration" )] migration_dir: String, diff --git a/sea-orm-cli/src/commands/migrate.rs b/sea-orm-cli/src/commands/migrate.rs index 8855c0cd..2b9d7c4a 100644 --- a/sea-orm-cli/src/commands/migrate.rs +++ b/sea-orm-cli/src/commands/migrate.rs @@ -1,6 +1,12 @@ use chrono::Local; use regex::Regex; -use std::{error::Error, fs, io::Write, path::Path, process::Command}; +use std::{ + error::Error, + fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; use crate::MigrateSubcommands; @@ -117,10 +123,27 @@ pub fn run_migrate_generate( Ok(()) } +/// `get_full_migration_dir` looks for a `src` directory +/// inside of `migration_dir` and appends that to the returned path if found. +/// +/// Otherwise, `migration_dir` can point directly to a directory containing the +/// migrations. In that case, nothing is appended. +/// +/// This way, `src` doesn't need to be appended in the standard case where +/// migrations are in their own crate. If the migrations are in a submodule +/// of another crate, `migration_dir` can point directly to that module. +fn get_full_migration_dir(migration_dir: &str) -> PathBuf { + let without_src = Path::new(migration_dir).to_owned(); + let with_src = without_src.join("src"); + match () { + _ if with_src.is_dir() => with_src, + _ => without_src, + } +} + fn create_new_migration(migration_name: &str, migration_dir: &str) -> Result<(), Box> { - let migration_filepath = Path::new(migration_dir) - .join("src") - .join(format!("{}.rs", &migration_name)); + let migration_filepath = + get_full_migration_dir(migration_dir).join(format!("{}.rs", &migration_name)); println!("Creating migration file `{}`", migration_filepath.display()); // TODO: make OS agnostic let migration_template = @@ -130,8 +153,29 @@ fn create_new_migration(migration_name: &str, migration_dir: &str) -> Result<(), Ok(()) } +/// `get_migrator_filepath` looks for a file `migration_dir/src/lib.rs` +/// and returns that path if found. +/// +/// If `src` is not found, it will look directly in `migration_dir` for `lib.rs`. +/// +/// If `lib.rs` is not found, it will look for `mod.rs` instead, +/// e.g. `migration_dir/mod.rs`. +/// +/// This way, `src` doesn't need to be appended in the standard case where +/// migrations are in their own crate (with a file `lib.rs`). If the +/// migrations are in a submodule of another crate (with a file `mod.rs`), +/// `migration_dir` can point directly to that module. +fn get_migrator_filepath(migration_dir: &str) -> PathBuf { + let full_migration_dir = get_full_migration_dir(migration_dir); + let with_lib = full_migration_dir.join("lib.rs"); + match () { + _ if with_lib.is_file() => with_lib, + _ => full_migration_dir.join("mod.rs"), + } +} + fn update_migrator(migration_name: &str, migration_dir: &str) -> Result<(), Box> { - let migrator_filepath = Path::new(migration_dir).join("src").join("lib.rs"); + let migrator_filepath = get_migrator_filepath(migration_dir); println!( "Adding migration `{}` to `{}`", migration_name, @@ -141,7 +185,7 @@ fn update_migrator(migration_name: &str, migration_dir: &str) -> Result<(), Box< let mut updated_migrator_content = migrator_content.clone(); // create a backup of the migrator file in case something goes wrong - let migrator_backup_filepath = migrator_filepath.with_file_name("lib.rs.bak"); + let migrator_backup_filepath = migrator_filepath.with_extension("rs.bak"); fs::copy(&migrator_filepath, &migrator_backup_filepath)?; let mut migrator_file = fs::File::create(&migrator_filepath)?; From 6f7529b0bef30b52d0528e00db4f735878f80d8f Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Mon, 19 Sep 2022 23:45:31 +0800 Subject: [PATCH 22/71] [cli] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09d389f..04c9b081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Enhancements * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 +* Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 ## 0.9.2 - 2022-08-20 From 968fc2e678d6dc39084f02cd688086c3e502544f Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Mon, 19 Sep 2022 23:50:11 +0800 Subject: [PATCH 23/71] Configure acquire timeout for connection pool (#897) * Reproduce "failed to acquire connection from pool" error * Configure acquire_timeout * Add tests * Fixup * Remove tests --- src/database/mod.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/database/mod.rs b/src/database/mod.rs index 4a24ec88..dda7dd02 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -38,6 +38,8 @@ pub struct ConnectOptions { /// Maximum idle time for a particular connection to prevent /// network resource exhaustion pub(crate) idle_timeout: Option, + /// Set the maximum amount of time to spend waiting for acquiring a connection + pub(crate) acquire_timeout: Option, /// Set the maximum lifetime of individual connections pub(crate) max_lifetime: Option, /// Enable SQLx statement logging @@ -107,6 +109,7 @@ impl ConnectOptions { min_connections: None, connect_timeout: None, idle_timeout: None, + acquire_timeout: None, max_lifetime: None, sqlx_logging: true, sqlx_logging_level: log::LevelFilter::Info, @@ -137,6 +140,9 @@ impl ConnectOptions { if let Some(idle_timeout) = self.idle_timeout { opt = opt.idle_timeout(Some(idle_timeout)); } + if let Some(acquire_timeout) = self.acquire_timeout { + opt = opt.acquire_timeout(acquire_timeout); + } if let Some(max_lifetime) = self.max_lifetime { opt = opt.max_lifetime(Some(max_lifetime)); } @@ -192,6 +198,17 @@ impl ConnectOptions { self.idle_timeout } + /// Set the maximum amount of time to spend waiting for acquiring a connection + pub fn acquire_timeout(&mut self, value: Duration) -> &mut Self { + self.acquire_timeout = Some(value); + self + } + + /// Get the maximum amount of time to spend waiting for acquiring a connection + pub fn get_acquire_timeout(&self) -> Option { + self.acquire_timeout + } + /// Set the maximum lifetime of individual connections pub fn max_lifetime(&mut self, lifetime: Duration) -> &mut Self { self.max_lifetime = Some(lifetime); From f3fb35564788433ebe484196c7cd624d91c90278 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Mon, 19 Sep 2022 23:51:35 +0800 Subject: [PATCH 24/71] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c9b081..c1d92c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 * Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 +* Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 ## 0.9.2 - 2022-08-20 From 5143307b3ef82fbcd3cfbbfb97d2041070a05932 Mon Sep 17 00:00:00 2001 From: Sanford Pun <47468266+shpun817@users.noreply.github.com> Date: Fri, 23 Sep 2022 05:57:43 +0100 Subject: [PATCH 25/71] Demonstrate how to mock test SeaORM by separating core implementation from the web API (#890) * Move core implementations to a standalone crate * Set up integration test skeleton in `core` * Demonstrate mock testing with query * Move Rocket api code to a standalone crate * Add mock execution * Add MyDataMyConsent in COMMUNITY.md (#889) * Add MyDataMyConsent in COMMUNITY.md * Update MyDataMyConsent description in COMMUNITY.md * Update COMMUNITY.md Chronological order * [cli] bump sea-schema to 0.9.3 (SeaQL/sea-orm#876) * Update CHNAGELOG PR links * 0.9.1 CHANGELOG * Auto discover and run all issues & examples CI (#903) * Auto discover and run all [issues] CI * Auto discover and run all examples CI * Fixup * Testing * Test [issues] * Compile prepare_mock_db() conditionally based on "mock" feature * Update workflow job to run mock test if `core` folder exists * Update Actix3 example * Fix merge conflict human error * Update usize used in paginate to u64 (PR#789) * Update sea-orm version in the Rocket example to 0.10.0 * Fix GitHub workflow to run mock test for core crates * Increase the robustness of core crate check by verifying that the `core` folder is a crate * Update Actix(4) example * Update Axum example * Update GraphQL example * Update Jsonrpsee example * Update Poem example * Update Tonic example * Cargo fmt * Update Salvo example * Update path of core/Cargo.toml in README.md * Add mock test instruction in README.md * Refactoring * Fix Rocket examples Co-authored-by: Amit Goyani <63532626+itsAmitGoyani@users.noreply.github.com> Co-authored-by: Billy Chan --- .github/workflows/rust.yml | 9 + examples/actix3_example/Cargo.toml | 27 +-- examples/actix3_example/README.md | 9 +- examples/actix3_example/api/Cargo.toml | 22 ++ examples/actix3_example/api/src/lib.rs | 219 +++++++++++++++++ .../{ => api}/static/css/normalize.css | 0 .../{ => api}/static/css/skeleton.css | 0 .../{ => api}/static/css/style.css | 0 .../{ => api}/static/images/favicon.png | Bin .../{ => api}/templates/edit.html.tera | 0 .../{ => api}/templates/error/404.html.tera | 0 .../{ => api}/templates/index.html.tera | 0 .../{ => api}/templates/layout.html.tera | 0 .../{ => api}/templates/new.html.tera | 0 examples/actix3_example/core/Cargo.toml | 30 +++ examples/actix3_example/core/src/lib.rs | 7 + examples/actix3_example/core/src/mutation.rs | 53 ++++ examples/actix3_example/core/src/query.rs | 26 ++ examples/actix3_example/core/tests/mock.rs | 79 ++++++ examples/actix3_example/core/tests/prepare.rs | 50 ++++ examples/actix3_example/src/main.rs | 228 +----------------- examples/actix_example/Cargo.toml | 27 +-- examples/actix_example/README.md | 9 +- examples/actix_example/api/Cargo.toml | 21 ++ examples/actix_example/api/src/lib.rs | 215 +++++++++++++++++ .../{ => api}/static/css/normalize.css | 0 .../{ => api}/static/css/skeleton.css | 0 .../{ => api}/static/css/style.css | 0 .../{ => api}/static/images/favicon.png | Bin .../{ => api}/templates/edit.html.tera | 0 .../{ => api}/templates/error/404.html.tera | 0 .../{ => api}/templates/index.html.tera | 0 .../{ => api}/templates/layout.html.tera | 0 .../{ => api}/templates/new.html.tera | 0 examples/actix_example/core/Cargo.toml | 30 +++ examples/actix_example/core/src/lib.rs | 7 + examples/actix_example/core/src/mutation.rs | 53 ++++ examples/actix_example/core/src/query.rs | 26 ++ examples/actix_example/core/tests/mock.rs | 79 ++++++ examples/actix_example/core/tests/prepare.rs | 50 ++++ examples/actix_example/src/main.rs | 224 +---------------- examples/axum_example/Cargo.toml | 28 +-- examples/axum_example/README.md | 9 +- examples/axum_example/api/Cargo.toml | 22 ++ examples/axum_example/{ => api}/src/flash.rs | 0 examples/axum_example/api/src/lib.rs | 210 ++++++++++++++++ .../{ => api}/static/css/normalize.css | 0 .../{ => api}/static/css/skeleton.css | 0 .../{ => api}/static/css/style.css | 0 .../{ => api}/static/images/favicon.png | Bin .../{ => api}/templates/edit.html.tera | 0 .../{ => api}/templates/error/404.html.tera | 0 .../{ => api}/templates/index.html.tera | 0 .../{ => api}/templates/layout.html.tera | 0 .../{ => api}/templates/new.html.tera | 0 examples/axum_example/core/Cargo.toml | 30 +++ examples/axum_example/core/src/lib.rs | 7 + examples/axum_example/core/src/mutation.rs | 53 ++++ examples/axum_example/core/src/query.rs | 26 ++ examples/axum_example/core/tests/mock.rs | 79 ++++++ examples/axum_example/core/tests/prepare.rs | 50 ++++ examples/axum_example/src/main.rs | 221 +---------------- examples/graphql_example/Cargo.toml | 19 +- examples/graphql_example/README.md | 9 +- examples/graphql_example/api/Cargo.toml | 15 ++ examples/graphql_example/api/src/db.rs | 21 ++ .../{ => api}/src/graphql/mod.rs | 0 .../{ => api}/src/graphql/mutation/mod.rs | 0 .../{ => api}/src/graphql/mutation/note.rs | 28 ++- .../{ => api}/src/graphql/query/mod.rs | 0 .../{ => api}/src/graphql/query/note.rs | 10 +- .../{ => api}/src/graphql/schema.rs | 0 examples/graphql_example/api/src/lib.rs | 49 ++++ examples/graphql_example/core/Cargo.toml | 30 +++ examples/graphql_example/core/src/lib.rs | 7 + examples/graphql_example/core/src/mutation.rs | 54 +++++ examples/graphql_example/core/src/query.rs | 30 +++ examples/graphql_example/core/tests/mock.rs | 79 ++++++ .../graphql_example/core/tests/prepare.rs | 50 ++++ examples/graphql_example/src/db.rs | 19 -- examples/graphql_example/src/main.rs | 50 +--- examples/jsonrpsee_example/Cargo.toml | 25 +- examples/jsonrpsee_example/README.md | 17 +- examples/jsonrpsee_example/api/Cargo.toml | 19 ++ examples/jsonrpsee_example/api/src/lib.rs | 143 +++++++++++ examples/jsonrpsee_example/core/Cargo.toml | 30 +++ examples/jsonrpsee_example/core/src/lib.rs | 7 + .../jsonrpsee_example/core/src/mutation.rs | 53 ++++ examples/jsonrpsee_example/core/src/query.rs | 26 ++ examples/jsonrpsee_example/core/tests/mock.rs | 79 ++++++ .../jsonrpsee_example/core/tests/prepare.rs | 50 ++++ examples/jsonrpsee_example/src/main.rs | 149 +----------- examples/poem_example/Cargo.toml | 22 +- examples/poem_example/README.md | 9 +- examples/poem_example/api/Cargo.toml | 15 ++ examples/poem_example/api/src/lib.rs | 163 +++++++++++++ .../{ => api}/static/css/normalize.css | 0 .../{ => api}/static/css/skeleton.css | 0 .../{ => api}/static/css/style.css | 0 .../{ => api}/static/images/favicon.png | Bin .../{ => api}/templates/edit.html.tera | 0 .../{ => api}/templates/error/404.html.tera | 0 .../{ => api}/templates/index.html.tera | 0 .../{ => api}/templates/layout.html.tera | 0 .../{ => api}/templates/new.html.tera | 0 examples/poem_example/core/Cargo.toml | 30 +++ examples/poem_example/core/src/lib.rs | 7 + examples/poem_example/core/src/mutation.rs | 53 ++++ examples/poem_example/core/src/query.rs | 26 ++ examples/poem_example/core/tests/mock.rs | 79 ++++++ examples/poem_example/core/tests/prepare.rs | 50 ++++ examples/poem_example/src/main.rs | 163 +------------ examples/rocket_example/Cargo.toml | 30 +-- examples/rocket_example/README.md | 11 +- examples/rocket_example/Rocket.toml | 2 +- examples/rocket_example/api/Cargo.toml | 27 +++ examples/rocket_example/api/src/lib.rs | 171 +++++++++++++ examples/rocket_example/{ => api}/src/pool.rs | 2 + .../{ => api}/static/css/normalize.css | 0 .../{ => api}/static/css/skeleton.css | 0 .../{ => api}/static/css/style.css | 0 .../{ => api}/static/images/favicon.png | Bin .../{ => api}/templates/base.html.tera | 0 .../{ => api}/templates/edit.html.tera | 0 .../{ => api}/templates/error/404.html.tera | 0 .../{ => api}/templates/index.html.tera | 0 .../{ => api}/templates/new.html.tera | 0 examples/rocket_example/core/Cargo.toml | 29 +++ examples/rocket_example/core/src/lib.rs | 7 + examples/rocket_example/core/src/mutation.rs | 53 ++++ examples/rocket_example/core/src/query.rs | 26 ++ examples/rocket_example/core/tests/mock.rs | 79 ++++++ examples/rocket_example/core/tests/prepare.rs | 50 ++++ examples/rocket_example/src/main.rs | 185 +------------- examples/salvo_example/Cargo.toml | 22 +- examples/salvo_example/README.md | 9 +- examples/salvo_example/api/Cargo.toml | 15 ++ examples/salvo_example/api/src/lib.rs | 182 ++++++++++++++ .../{ => api}/static/css/normalize.css | 0 .../{ => api}/static/css/skeleton.css | 0 .../{ => api}/static/css/style.css | 0 .../{ => api}/static/images/favicon.png | Bin .../{ => api}/templates/edit.html.tera | 0 .../{ => api}/templates/error/404.html.tera | 0 .../{ => api}/templates/index.html.tera | 0 .../{ => api}/templates/layout.html.tera | 0 .../{ => api}/templates/new.html.tera | 0 examples/salvo_example/core/Cargo.toml | 30 +++ examples/salvo_example/core/src/lib.rs | 7 + examples/salvo_example/core/src/mutation.rs | 53 ++++ examples/salvo_example/core/src/query.rs | 26 ++ examples/salvo_example/core/tests/mock.rs | 79 ++++++ examples/salvo_example/core/tests/prepare.rs | 50 ++++ examples/salvo_example/src/main.rs | 192 +-------------- examples/tonic_example/Cargo.toml | 32 +-- examples/tonic_example/README.md | 11 +- examples/tonic_example/api/Cargo.toml | 20 ++ examples/tonic_example/{ => api}/build.rs | 0 .../tonic_example/{ => api}/proto/post.proto | 0 examples/tonic_example/api/src/lib.rs | 143 +++++++++++ examples/tonic_example/core/Cargo.toml | 30 +++ examples/tonic_example/core/src/lib.rs | 7 + examples/tonic_example/core/src/mutation.rs | 53 ++++ examples/tonic_example/core/src/query.rs | 26 ++ examples/tonic_example/core/tests/mock.rs | 79 ++++++ examples/tonic_example/core/tests/prepare.rs | 50 ++++ examples/tonic_example/src/client.rs | 2 +- examples/tonic_example/src/lib.rs | 3 - examples/tonic_example/src/server.rs | 131 +--------- 169 files changed, 4055 insertions(+), 1789 deletions(-) create mode 100644 examples/actix3_example/api/Cargo.toml create mode 100644 examples/actix3_example/api/src/lib.rs rename examples/actix3_example/{ => api}/static/css/normalize.css (100%) rename examples/actix3_example/{ => api}/static/css/skeleton.css (100%) rename examples/actix3_example/{ => api}/static/css/style.css (100%) rename examples/actix3_example/{ => api}/static/images/favicon.png (100%) rename examples/actix3_example/{ => api}/templates/edit.html.tera (100%) rename examples/actix3_example/{ => api}/templates/error/404.html.tera (100%) rename examples/actix3_example/{ => api}/templates/index.html.tera (100%) rename examples/actix3_example/{ => api}/templates/layout.html.tera (100%) rename examples/actix3_example/{ => api}/templates/new.html.tera (100%) create mode 100644 examples/actix3_example/core/Cargo.toml create mode 100644 examples/actix3_example/core/src/lib.rs create mode 100644 examples/actix3_example/core/src/mutation.rs create mode 100644 examples/actix3_example/core/src/query.rs create mode 100644 examples/actix3_example/core/tests/mock.rs create mode 100644 examples/actix3_example/core/tests/prepare.rs create mode 100644 examples/actix_example/api/Cargo.toml create mode 100644 examples/actix_example/api/src/lib.rs rename examples/actix_example/{ => api}/static/css/normalize.css (100%) rename examples/actix_example/{ => api}/static/css/skeleton.css (100%) rename examples/actix_example/{ => api}/static/css/style.css (100%) rename examples/actix_example/{ => api}/static/images/favicon.png (100%) rename examples/actix_example/{ => api}/templates/edit.html.tera (100%) rename examples/actix_example/{ => api}/templates/error/404.html.tera (100%) rename examples/actix_example/{ => api}/templates/index.html.tera (100%) rename examples/actix_example/{ => api}/templates/layout.html.tera (100%) rename examples/actix_example/{ => api}/templates/new.html.tera (100%) create mode 100644 examples/actix_example/core/Cargo.toml create mode 100644 examples/actix_example/core/src/lib.rs create mode 100644 examples/actix_example/core/src/mutation.rs create mode 100644 examples/actix_example/core/src/query.rs create mode 100644 examples/actix_example/core/tests/mock.rs create mode 100644 examples/actix_example/core/tests/prepare.rs create mode 100644 examples/axum_example/api/Cargo.toml rename examples/axum_example/{ => api}/src/flash.rs (100%) create mode 100644 examples/axum_example/api/src/lib.rs rename examples/axum_example/{ => api}/static/css/normalize.css (100%) rename examples/axum_example/{ => api}/static/css/skeleton.css (100%) rename examples/axum_example/{ => api}/static/css/style.css (100%) rename examples/axum_example/{ => api}/static/images/favicon.png (100%) rename examples/axum_example/{ => api}/templates/edit.html.tera (100%) rename examples/axum_example/{ => api}/templates/error/404.html.tera (100%) rename examples/axum_example/{ => api}/templates/index.html.tera (100%) rename examples/axum_example/{ => api}/templates/layout.html.tera (100%) rename examples/axum_example/{ => api}/templates/new.html.tera (100%) create mode 100644 examples/axum_example/core/Cargo.toml create mode 100644 examples/axum_example/core/src/lib.rs create mode 100644 examples/axum_example/core/src/mutation.rs create mode 100644 examples/axum_example/core/src/query.rs create mode 100644 examples/axum_example/core/tests/mock.rs create mode 100644 examples/axum_example/core/tests/prepare.rs create mode 100644 examples/graphql_example/api/Cargo.toml create mode 100644 examples/graphql_example/api/src/db.rs rename examples/graphql_example/{ => api}/src/graphql/mod.rs (100%) rename examples/graphql_example/{ => api}/src/graphql/mutation/mod.rs (100%) rename examples/graphql_example/{ => api}/src/graphql/mutation/note.rs (68%) rename examples/graphql_example/{ => api}/src/graphql/query/mod.rs (100%) rename examples/graphql_example/{ => api}/src/graphql/query/note.rs (75%) rename examples/graphql_example/{ => api}/src/graphql/schema.rs (100%) create mode 100644 examples/graphql_example/api/src/lib.rs create mode 100644 examples/graphql_example/core/Cargo.toml create mode 100644 examples/graphql_example/core/src/lib.rs create mode 100644 examples/graphql_example/core/src/mutation.rs create mode 100644 examples/graphql_example/core/src/query.rs create mode 100644 examples/graphql_example/core/tests/mock.rs create mode 100644 examples/graphql_example/core/tests/prepare.rs delete mode 100644 examples/graphql_example/src/db.rs create mode 100644 examples/jsonrpsee_example/api/Cargo.toml create mode 100644 examples/jsonrpsee_example/api/src/lib.rs create mode 100644 examples/jsonrpsee_example/core/Cargo.toml create mode 100644 examples/jsonrpsee_example/core/src/lib.rs create mode 100644 examples/jsonrpsee_example/core/src/mutation.rs create mode 100644 examples/jsonrpsee_example/core/src/query.rs create mode 100644 examples/jsonrpsee_example/core/tests/mock.rs create mode 100644 examples/jsonrpsee_example/core/tests/prepare.rs create mode 100644 examples/poem_example/api/Cargo.toml create mode 100644 examples/poem_example/api/src/lib.rs rename examples/poem_example/{ => api}/static/css/normalize.css (100%) rename examples/poem_example/{ => api}/static/css/skeleton.css (100%) rename examples/poem_example/{ => api}/static/css/style.css (100%) rename examples/poem_example/{ => api}/static/images/favicon.png (100%) rename examples/poem_example/{ => api}/templates/edit.html.tera (100%) rename examples/poem_example/{ => api}/templates/error/404.html.tera (100%) rename examples/poem_example/{ => api}/templates/index.html.tera (100%) rename examples/poem_example/{ => api}/templates/layout.html.tera (100%) rename examples/poem_example/{ => api}/templates/new.html.tera (100%) create mode 100644 examples/poem_example/core/Cargo.toml create mode 100644 examples/poem_example/core/src/lib.rs create mode 100644 examples/poem_example/core/src/mutation.rs create mode 100644 examples/poem_example/core/src/query.rs create mode 100644 examples/poem_example/core/tests/mock.rs create mode 100644 examples/poem_example/core/tests/prepare.rs create mode 100644 examples/rocket_example/api/Cargo.toml create mode 100644 examples/rocket_example/api/src/lib.rs rename examples/rocket_example/{ => api}/src/pool.rs (97%) rename examples/rocket_example/{ => api}/static/css/normalize.css (100%) rename examples/rocket_example/{ => api}/static/css/skeleton.css (100%) rename examples/rocket_example/{ => api}/static/css/style.css (100%) rename examples/rocket_example/{ => api}/static/images/favicon.png (100%) rename examples/rocket_example/{ => api}/templates/base.html.tera (100%) rename examples/rocket_example/{ => api}/templates/edit.html.tera (100%) rename examples/rocket_example/{ => api}/templates/error/404.html.tera (100%) rename examples/rocket_example/{ => api}/templates/index.html.tera (100%) rename examples/rocket_example/{ => api}/templates/new.html.tera (100%) create mode 100644 examples/rocket_example/core/Cargo.toml create mode 100644 examples/rocket_example/core/src/lib.rs create mode 100644 examples/rocket_example/core/src/mutation.rs create mode 100644 examples/rocket_example/core/src/query.rs create mode 100644 examples/rocket_example/core/tests/mock.rs create mode 100644 examples/rocket_example/core/tests/prepare.rs create mode 100644 examples/salvo_example/api/Cargo.toml create mode 100644 examples/salvo_example/api/src/lib.rs rename examples/salvo_example/{ => api}/static/css/normalize.css (100%) rename examples/salvo_example/{ => api}/static/css/skeleton.css (100%) rename examples/salvo_example/{ => api}/static/css/style.css (100%) rename examples/salvo_example/{ => api}/static/images/favicon.png (100%) rename examples/salvo_example/{ => api}/templates/edit.html.tera (100%) rename examples/salvo_example/{ => api}/templates/error/404.html.tera (100%) rename examples/salvo_example/{ => api}/templates/index.html.tera (100%) rename examples/salvo_example/{ => api}/templates/layout.html.tera (100%) rename examples/salvo_example/{ => api}/templates/new.html.tera (100%) create mode 100644 examples/salvo_example/core/Cargo.toml create mode 100644 examples/salvo_example/core/src/lib.rs create mode 100644 examples/salvo_example/core/src/mutation.rs create mode 100644 examples/salvo_example/core/src/query.rs create mode 100644 examples/salvo_example/core/tests/mock.rs create mode 100644 examples/salvo_example/core/tests/prepare.rs create mode 100644 examples/tonic_example/api/Cargo.toml rename examples/tonic_example/{ => api}/build.rs (100%) rename examples/tonic_example/{ => api}/proto/post.proto (100%) create mode 100644 examples/tonic_example/api/src/lib.rs create mode 100644 examples/tonic_example/core/Cargo.toml create mode 100644 examples/tonic_example/core/src/lib.rs create mode 100644 examples/tonic_example/core/src/mutation.rs create mode 100644 examples/tonic_example/core/src/query.rs create mode 100644 examples/tonic_example/core/tests/mock.rs create mode 100644 examples/tonic_example/core/tests/prepare.rs delete mode 100644 examples/tonic_example/src/lib.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d4c11de9..037d21de 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -405,6 +405,15 @@ jobs: args: > --manifest-path ${{ matrix.path }} + - name: Run mock test if it is core crate + uses: actions-rs/cargo@v1 + if: ${{ contains(matrix.path, 'core/Cargo.toml') }} + with: + command: test + args: > + --manifest-path ${{ matrix.path }} + --features mock + - name: check rustfmt run: | rustup override set nightly diff --git a/examples/actix3_example/Cargo.toml b/examples/actix3_example/Cargo.toml index d08d1dcd..a449de4c 100644 --- a/examples/actix3_example/Cargo.toml +++ b/examples/actix3_example/Cargo.toml @@ -6,30 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -actix-http = "2" -actix-web = "3" -actix-flash = "0.2" -actix-files = "0.5" -futures = { version = "^0.3" } -futures-util = { version = "^0.3" } -tera = "1.8.0" -dotenv = "0.15" -listenfd = "0.3.3" -serde = "1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-async-std-native-tls", - "sqlx-mysql", - # "sqlx-postgres", - # "sqlx-sqlite", -] +actix3-example-api = { path = "api" } diff --git a/examples/actix3_example/README.md b/examples/actix3_example/README.md index 2cd8c358..47263826 100644 --- a/examples/actix3_example/README.md +++ b/examples/actix3_example/README.md @@ -6,7 +6,7 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-mysql",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-mysql",` line) 1. Execute `cargo run` to start the server @@ -18,3 +18,10 @@ Run server with auto-reloading: cargo install systemfd systemfd --no-pid -s http::8000 -- cargo watch -x run ``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/actix3_example/api/Cargo.toml b/examples/actix3_example/api/Cargo.toml new file mode 100644 index 00000000..5f38712e --- /dev/null +++ b/examples/actix3_example/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "actix3-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +actix3-example-core = { path = "../core" } +actix-http = "2" +actix-web = "3" +actix-flash = "0.2" +actix-files = "0.5" +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +tera = "1.8.0" +dotenv = "0.15" +listenfd = "0.3.3" +serde = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/actix3_example/api/src/lib.rs b/examples/actix3_example/api/src/lib.rs new file mode 100644 index 00000000..275e4a7c --- /dev/null +++ b/examples/actix3_example/api/src/lib.rs @@ -0,0 +1,219 @@ +use actix3_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use actix_files as fs; +use actix_web::{ + error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, +}; + +use entity::post; +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::env; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[get("/")] +async fn list( + req: HttpRequest, + data: web::Data, + opt_flash: Option>, +) -> Result { + let template = &data.templates; + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string()).unwrap(); + + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + if let Some(flash) = opt_flash { + let flash_inner = flash.into_inner(); + ctx.insert("flash", &flash_inner); + } + + let body = template + .render("index.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[get("/new")] +async fn new(data: web::Data) -> Result { + let template = &data.templates; + let ctx = tera::Context::new(); + let body = template + .render("new.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/")] +async fn create( + data: web::Data, + post_form: web::Form, +) -> actix_flash::Response { + let conn = &data.conn; + + let form = post_form.into_inner(); + + Mutation::create_post(conn, form) + .await + .expect("could not insert post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully added.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[get("/{id}")] +async fn edit(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let template = &data.templates; + let id = id.into_inner(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = template + .render("edit.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/{id}")] +async fn update( + data: web::Data, + id: web::Path, + post_form: web::Form, +) -> actix_flash::Response { + let conn = &data.conn; + let form = post_form.into_inner(); + let id = id.into_inner(); + + Mutation::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully updated.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[post("/delete/{id}")] +async fn delete( + data: web::Data, + id: web::Path, +) -> actix_flash::Response { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_post(conn, id) + .await + .expect("could not delete post"); + + let flash = FlashData { + kind: "success".to_owned(), + message: "Post successfully deleted.".to_owned(), + }; + + actix_flash::Response::with_redirect(flash, "/") +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .data(state.clone()) + .wrap(middleware::Logger::default()) // enable logger + .wrap(actix_flash::Flash::default()) + .configure(init) + .service(fs::Files::new("/static", "./api/static").show_files_listing()) + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => server.bind(&server_url)?, + }; + + println!("Starting server at {}", server_url); + server.run().await?; + + Ok(()) +} + +fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list); + cfg.service(new); + cfg.service(create); + cfg.service(edit); + cfg.service(update); + cfg.service(delete); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err) + } +} diff --git a/examples/actix3_example/static/css/normalize.css b/examples/actix3_example/api/static/css/normalize.css similarity index 100% rename from examples/actix3_example/static/css/normalize.css rename to examples/actix3_example/api/static/css/normalize.css diff --git a/examples/actix3_example/static/css/skeleton.css b/examples/actix3_example/api/static/css/skeleton.css similarity index 100% rename from examples/actix3_example/static/css/skeleton.css rename to examples/actix3_example/api/static/css/skeleton.css diff --git a/examples/actix3_example/static/css/style.css b/examples/actix3_example/api/static/css/style.css similarity index 100% rename from examples/actix3_example/static/css/style.css rename to examples/actix3_example/api/static/css/style.css diff --git a/examples/actix3_example/static/images/favicon.png b/examples/actix3_example/api/static/images/favicon.png similarity index 100% rename from examples/actix3_example/static/images/favicon.png rename to examples/actix3_example/api/static/images/favicon.png diff --git a/examples/actix3_example/templates/edit.html.tera b/examples/actix3_example/api/templates/edit.html.tera similarity index 100% rename from examples/actix3_example/templates/edit.html.tera rename to examples/actix3_example/api/templates/edit.html.tera diff --git a/examples/actix3_example/templates/error/404.html.tera b/examples/actix3_example/api/templates/error/404.html.tera similarity index 100% rename from examples/actix3_example/templates/error/404.html.tera rename to examples/actix3_example/api/templates/error/404.html.tera diff --git a/examples/actix3_example/templates/index.html.tera b/examples/actix3_example/api/templates/index.html.tera similarity index 100% rename from examples/actix3_example/templates/index.html.tera rename to examples/actix3_example/api/templates/index.html.tera diff --git a/examples/actix3_example/templates/layout.html.tera b/examples/actix3_example/api/templates/layout.html.tera similarity index 100% rename from examples/actix3_example/templates/layout.html.tera rename to examples/actix3_example/api/templates/layout.html.tera diff --git a/examples/actix3_example/templates/new.html.tera b/examples/actix3_example/api/templates/new.html.tera similarity index 100% rename from examples/actix3_example/templates/new.html.tera rename to examples/actix3_example/api/templates/new.html.tera diff --git a/examples/actix3_example/core/Cargo.toml b/examples/actix3_example/core/Cargo.toml new file mode 100644 index 00000000..c0548ff4 --- /dev/null +++ b/examples/actix3_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actix3-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-mysql", + # "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/actix3_example/core/src/lib.rs b/examples/actix3_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/actix3_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/actix3_example/core/src/mutation.rs b/examples/actix3_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/actix3_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/actix3_example/core/src/query.rs b/examples/actix3_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/actix3_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/actix3_example/core/tests/mock.rs b/examples/actix3_example/core/tests/mock.rs new file mode 100644 index 00000000..190cb290 --- /dev/null +++ b/examples/actix3_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix3_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/actix3_example/core/tests/prepare.rs b/examples/actix3_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/actix3_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/actix3_example/src/main.rs b/examples/actix3_example/src/main.rs index f6ac5d6b..5a0e63b8 100644 --- a/examples/actix3_example/src/main.rs +++ b/examples/actix3_example/src/main.rs @@ -1,227 +1,3 @@ -use actix_files as fs; -use actix_web::{ - error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, -}; - -use entity::post; -use entity::post::Entity as Post; -use listenfd::ListenFd; -use migration::{Migrator, MigratorTrait}; -use sea_orm::DatabaseConnection; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use std::env; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} -#[derive(Debug, Deserialize)] -pub struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -#[get("/")] -async fn list( - req: HttpRequest, - data: web::Data, - opt_flash: Option>, -) -> Result { - let template = &data.templates; - let conn = &data.conn; - - // get params - let params = web::Query::::from_query(req.query_string()).unwrap(); - - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - if let Some(flash) = opt_flash { - let flash_inner = flash.into_inner(); - ctx.insert("flash", &flash_inner); - } - - let body = template - .render("index.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[get("/new")] -async fn new(data: web::Data) -> Result { - let template = &data.templates; - let ctx = tera::Context::new(); - let body = template - .render("new.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/")] -async fn create( - data: web::Data, - post_form: web::Form, -) -> actix_flash::Response { - let conn = &data.conn; - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully added.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[get("/{id}")] -async fn edit(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - let template = &data.templates; - - let post: post::Model = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = template - .render("edit.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/{id}")] -async fn update( - data: web::Data, - id: web::Path, - post_form: web::Form, -) -> actix_flash::Response { - let conn = &data.conn; - let form = post_form.into_inner(); - - post::ActiveModel { - id: Set(id.into_inner()), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully updated.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[post("/delete/{id}")] -async fn delete( - data: web::Data, - id: web::Path, -) -> actix_flash::Response { - let conn = &data.conn; - - let post: post::ActiveModel = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - let flash = FlashData { - kind: "success".to_owned(), - message: "Post successfully deleted.".to_owned(), - }; - - actix_flash::Response::with_redirect(flash, "/") -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(move || { - App::new() - .data(state.clone()) - .wrap(middleware::Logger::default()) // enable logger - .wrap(actix_flash::Flash::default()) - .configure(init) - .service(fs::Files::new("/static", "./static").show_files_listing()) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => server.bind(&server_url)?, - }; - - println!("Starting server at {}", server_url); - server.run().await?; - - Ok(()) -} - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(list); - cfg.service(new); - cfg.service(create); - cfg.service(edit); - cfg.service(update); - cfg.service(delete); +fn main() { + actix3_example_api::main(); } diff --git a/examples/actix_example/Cargo.toml b/examples/actix_example/Cargo.toml index 8be0a188..7dc877c2 100644 --- a/examples/actix_example/Cargo.toml +++ b/examples/actix_example/Cargo.toml @@ -6,30 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -actix-files = "0.6" -actix-http = "3" -actix-rt = "2.7" -actix-service = "2" -actix-web = "4" - -tera = "1.15.0" -dotenv = "0.15" -listenfd = "0.5" -serde = "1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-actix-native-tls", - "sqlx-mysql", - # "sqlx-postgres", - # "sqlx-sqlite", -] +actix-example-api = { path = "api" } diff --git a/examples/actix_example/README.md b/examples/actix_example/README.md index a2acf414..d844b96a 100644 --- a/examples/actix_example/README.md +++ b/examples/actix_example/README.md @@ -4,7 +4,7 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-mysql",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-mysql",` line) 1. Execute `cargo run` to start the server @@ -16,3 +16,10 @@ Run server with auto-reloading: cargo install systemfd cargo-watch systemfd --no-pid -s http::8000 -- cargo watch -x run ``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/actix_example/api/Cargo.toml b/examples/actix_example/api/Cargo.toml new file mode 100644 index 00000000..078e5561 --- /dev/null +++ b/examples/actix_example/api/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "actix-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +actix-example-core = { path = "../core" } +actix-files = "0.6" +actix-http = "3" +actix-rt = "2.7" +actix-service = "2" +actix-web = "4" +tera = "1.15.0" +dotenv = "0.15" +listenfd = "0.5" +serde = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/actix_example/api/src/lib.rs b/examples/actix_example/api/src/lib.rs new file mode 100644 index 00000000..f95200e8 --- /dev/null +++ b/examples/actix_example/api/src/lib.rs @@ -0,0 +1,215 @@ +use actix_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use actix_files::Files as Fs; +use actix_web::{ + error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, +}; + +use entity::post; +use listenfd::ListenFd; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::env; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[derive(Debug, Deserialize)] +pub struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +#[get("/")] +async fn list(req: HttpRequest, data: web::Data) -> Result { + let template = &data.templates; + let conn = &data.conn; + + // get params + let params = web::Query::::from_query(req.query_string()).unwrap(); + + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = template + .render("index.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[get("/new")] +async fn new(data: web::Data) -> Result { + let template = &data.templates; + let ctx = tera::Context::new(); + let body = template + .render("new.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/")] +async fn create( + data: web::Data, + post_form: web::Form, +) -> Result { + let conn = &data.conn; + + let form = post_form.into_inner(); + + Mutation::create_post(conn, form) + .await + .expect("could not insert post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +#[get("/{id}")] +async fn edit(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let template = &data.templates; + let id = id.into_inner(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = template + .render("edit.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[post("/{id}")] +async fn update( + data: web::Data, + id: web::Path, + post_form: web::Form, +) -> Result { + let conn = &data.conn; + let form = post_form.into_inner(); + let id = id.into_inner(); + + Mutation::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +#[post("/delete/{id}")] +async fn delete(data: web::Data, id: web::Path) -> Result { + let conn = &data.conn; + let id = id.into_inner(); + + Mutation::delete_post(conn, id) + .await + .expect("could not delete post"); + + Ok(HttpResponse::Found() + .append_header(("location", "/")) + .finish()) +} + +async fn not_found(data: web::Data, request: HttpRequest) -> Result { + let mut ctx = tera::Context::new(); + ctx.insert("uri", request.uri().path()); + + let template = &data.templates; + let body = template + .render("error/404.html.tera", &ctx) + .map_err(|_| error::ErrorInternalServerError("Template error"))?; + + Ok(HttpResponse::Ok().content_type("text/html").body(body)) +} + +#[actix_web::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // establish connection to database and apply migrations + // -> create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + // load tera templates and build app state + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + // create server and try to serve over socket if possible + let mut listenfd = ListenFd::from_env(); + let mut server = HttpServer::new(move || { + App::new() + .service(Fs::new("/static", "./api/static")) + .app_data(web::Data::new(state.clone())) + .wrap(middleware::Logger::default()) // enable logger + .default_service(web::route().to(not_found)) + .configure(init) + }); + + server = match listenfd.take_tcp_listener(0)? { + Some(listener) => server.listen(listener)?, + None => server.bind(&server_url)?, + }; + + println!("Starting server at {}", server_url); + server.run().await?; + + Ok(()) +} + +fn init(cfg: &mut web::ServiceConfig) { + cfg.service(list); + cfg.service(new); + cfg.service(create); + cfg.service(edit); + cfg.service(update); + cfg.service(delete); +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/actix_example/static/css/normalize.css b/examples/actix_example/api/static/css/normalize.css similarity index 100% rename from examples/actix_example/static/css/normalize.css rename to examples/actix_example/api/static/css/normalize.css diff --git a/examples/actix_example/static/css/skeleton.css b/examples/actix_example/api/static/css/skeleton.css similarity index 100% rename from examples/actix_example/static/css/skeleton.css rename to examples/actix_example/api/static/css/skeleton.css diff --git a/examples/actix_example/static/css/style.css b/examples/actix_example/api/static/css/style.css similarity index 100% rename from examples/actix_example/static/css/style.css rename to examples/actix_example/api/static/css/style.css diff --git a/examples/actix_example/static/images/favicon.png b/examples/actix_example/api/static/images/favicon.png similarity index 100% rename from examples/actix_example/static/images/favicon.png rename to examples/actix_example/api/static/images/favicon.png diff --git a/examples/actix_example/templates/edit.html.tera b/examples/actix_example/api/templates/edit.html.tera similarity index 100% rename from examples/actix_example/templates/edit.html.tera rename to examples/actix_example/api/templates/edit.html.tera diff --git a/examples/actix_example/templates/error/404.html.tera b/examples/actix_example/api/templates/error/404.html.tera similarity index 100% rename from examples/actix_example/templates/error/404.html.tera rename to examples/actix_example/api/templates/error/404.html.tera diff --git a/examples/actix_example/templates/index.html.tera b/examples/actix_example/api/templates/index.html.tera similarity index 100% rename from examples/actix_example/templates/index.html.tera rename to examples/actix_example/api/templates/index.html.tera diff --git a/examples/actix_example/templates/layout.html.tera b/examples/actix_example/api/templates/layout.html.tera similarity index 100% rename from examples/actix_example/templates/layout.html.tera rename to examples/actix_example/api/templates/layout.html.tera diff --git a/examples/actix_example/templates/new.html.tera b/examples/actix_example/api/templates/new.html.tera similarity index 100% rename from examples/actix_example/templates/new.html.tera rename to examples/actix_example/api/templates/new.html.tera diff --git a/examples/actix_example/core/Cargo.toml b/examples/actix_example/core/Cargo.toml new file mode 100644 index 00000000..2044b6f2 --- /dev/null +++ b/examples/actix_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actix-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-mysql", + # "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/actix_example/core/src/lib.rs b/examples/actix_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/actix_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/actix_example/core/src/mutation.rs b/examples/actix_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/actix_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/actix_example/core/src/query.rs b/examples/actix_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/actix_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/actix_example/core/tests/mock.rs b/examples/actix_example/core/tests/mock.rs new file mode 100644 index 00000000..76531b67 --- /dev/null +++ b/examples/actix_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use actix_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/actix_example/core/tests/prepare.rs b/examples/actix_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/actix_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/actix_example/src/main.rs b/examples/actix_example/src/main.rs index fa55c507..a9cdecdc 100644 --- a/examples/actix_example/src/main.rs +++ b/examples/actix_example/src/main.rs @@ -1,223 +1,3 @@ -use actix_files::Files as Fs; -use actix_web::{ - error, get, middleware, post, web, App, Error, HttpRequest, HttpResponse, HttpServer, Result, -}; - -use entity::post; -use entity::post::Entity as Post; -use listenfd::ListenFd; -use migration::{Migrator, MigratorTrait}; -use sea_orm::DatabaseConnection; -use sea_orm::{entity::*, query::*}; -use serde::{Deserialize, Serialize}; -use std::env; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[derive(Debug, Deserialize)] -pub struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -#[get("/")] -async fn list(req: HttpRequest, data: web::Data) -> Result { - let template = &data.templates; - let conn = &data.conn; - - // get params - let params = web::Query::::from_query(req.query_string()).unwrap(); - - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = template - .render("index.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[get("/new")] -async fn new(data: web::Data) -> Result { - let template = &data.templates; - let ctx = tera::Context::new(); - let body = template - .render("new.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/")] -async fn create( - data: web::Data, - post_form: web::Form, -) -> Result { - let conn = &data.conn; - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -#[get("/{id}")] -async fn edit(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - let template = &data.templates; - - let post: post::Model = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = template - .render("edit.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[post("/{id}")] -async fn update( - data: web::Data, - id: web::Path, - post_form: web::Form, -) -> Result { - let conn = &data.conn; - let form = post_form.into_inner(); - - post::ActiveModel { - id: Set(id.into_inner()), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -#[post("/delete/{id}")] -async fn delete(data: web::Data, id: web::Path) -> Result { - let conn = &data.conn; - - let post: post::ActiveModel = Post::find_by_id(id.into_inner()) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - Ok(HttpResponse::Found() - .append_header(("location", "/")) - .finish()) -} - -async fn not_found(data: web::Data, request: HttpRequest) -> Result { - let mut ctx = tera::Context::new(); - ctx.insert("uri", request.uri().path()); - - let template = &data.templates; - let body = template - .render("error/404.html.tera", &ctx) - .map_err(|_| error::ErrorInternalServerError("Template error"))?; - - Ok(HttpResponse::Ok().content_type("text/html").body(body)) -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // establish connection to database and apply migrations - // -> create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - - // load tera templates and build app state - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - // create server and try to serve over socket if possible - let mut listenfd = ListenFd::from_env(); - let mut server = HttpServer::new(move || { - App::new() - .service(Fs::new("/static", "./static")) - .app_data(web::Data::new(state.clone())) - .wrap(middleware::Logger::default()) // enable logger - .default_service(web::route().to(not_found)) - .configure(init) - }); - - server = match listenfd.take_tcp_listener(0)? { - Some(listener) => server.listen(listener)?, - None => server.bind(&server_url)?, - }; - - println!("Starting server at {}", server_url); - server.run().await?; - - Ok(()) -} - -pub fn init(cfg: &mut web::ServiceConfig) { - cfg.service(list); - cfg.service(new); - cfg.service(create); - cfg.service(edit); - cfg.service(update); - cfg.service(delete); +fn main() { + actix_example_api::main(); } diff --git a/examples/axum_example/Cargo.toml b/examples/axum_example/Cargo.toml index 43478ad0..0387b657 100644 --- a/examples/axum_example/Cargo.toml +++ b/examples/axum_example/Cargo.toml @@ -5,32 +5,8 @@ authors = ["Yoshiera Huang "] edition = "2021" publish = false -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.18.1", features = ["full"] } -axum = "0.5.4" -tower = "0.4.12" -tower-http = { version = "0.3.3", features = ["fs"] } -tower-cookies = "0.6.0" -anyhow = "1.0.57" -dotenv = "0.15.0" -serde = "1.0.137" -serde_json = "1.0.81" -tera = "1.15.0" -tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-postgres", - # "sqlx-mysql", - # "sqlx-sqlite", -] +axum-example-api = { path = "api" } diff --git a/examples/axum_example/README.md b/examples/axum_example/README.md index a3c62422..fe120e71 100644 --- a/examples/axum_example/README.md +++ b/examples/axum_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-postgres",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-postgres",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/axum_example/api/Cargo.toml b/examples/axum_example/api/Cargo.toml new file mode 100644 index 00000000..dc1f5ce4 --- /dev/null +++ b/examples/axum_example/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "axum-example-api" +version = "0.1.0" +authors = ["Yoshiera Huang "] +edition = "2021" +publish = false + +[dependencies] +axum-example-core = { path = "../core" } +tokio = { version = "1.18.1", features = ["full"] } +axum = "0.5.4" +tower = "0.4.12" +tower-http = { version = "0.3.3", features = ["fs"] } +tower-cookies = "0.6.0" +anyhow = "1.0.57" +dotenv = "0.15.0" +serde = "1.0.137" +serde_json = "1.0.81" +tera = "1.15.0" +tracing-subscriber = { version = "0.3.11", features = ["env-filter"] } +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/axum_example/src/flash.rs b/examples/axum_example/api/src/flash.rs similarity index 100% rename from examples/axum_example/src/flash.rs rename to examples/axum_example/api/src/flash.rs diff --git a/examples/axum_example/api/src/lib.rs b/examples/axum_example/api/src/lib.rs new file mode 100644 index 00000000..ead63e7b --- /dev/null +++ b/examples/axum_example/api/src/lib.rs @@ -0,0 +1,210 @@ +mod flash; + +use axum::{ + extract::{Extension, Form, Path, Query}, + http::StatusCode, + response::Html, + routing::{get, get_service, post}, + Router, Server, +}; +use axum_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation as MutationCore, Query as QueryCore, +}; +use entity::post; +use flash::{get_flash_cookie, post_response, PostResponse}; +use migration::{Migrator, MigratorTrait}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use std::{env, net::SocketAddr}; +use tera::Tera; +use tower::ServiceBuilder; +use tower_cookies::{CookieManagerLayer, Cookies}; +use tower_http::services::ServeDir; + +#[tokio::main] +async fn start() -> anyhow::Result<()> { + env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + let conn = Database::connect(db_url) + .await + .expect("Database connection failed"); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")) + .expect("Tera initialization failed"); + // let state = AppState { templates, conn }; + + let app = Router::new() + .route("/", get(list_posts).post(create_post)) + .route("/:id", get(edit_post).post(update_post)) + .route("/new", get(new_post)) + .route("/delete/:id", post(delete_post)) + .nest( + "/static", + get_service(ServeDir::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/static" + ))) + .handle_error(|error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }), + ) + .layer( + ServiceBuilder::new() + .layer(CookieManagerLayer::new()) + .layer(Extension(conn)) + .layer(Extension(templates)), + ); + + let addr = SocketAddr::from_str(&server_url).unwrap(); + Server::bind(&addr).serve(app.into_make_service()).await?; + + Ok(()) +} + +#[derive(Deserialize)] +struct Params { + page: Option, + posts_per_page: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +struct FlashData { + kind: String, + message: String, +} + +async fn list_posts( + Extension(ref templates): Extension, + Extension(ref conn): Extension, + Query(params): Query, + cookies: Cookies, +) -> Result, (StatusCode, &'static str)> { + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(5); + + let (posts, num_pages) = QueryCore::find_posts_in_page(conn, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + if let Some(value) = get_flash_cookie::(&cookies) { + ctx.insert("flash", &value); + } + + let body = templates + .render("index.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn new_post( + Extension(ref templates): Extension, +) -> Result, (StatusCode, &'static str)> { + let ctx = tera::Context::new(); + let body = templates + .render("new.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn create_post( + Extension(ref conn): Extension, + form: Form, + mut cookies: Cookies, +) -> Result { + let form = form.0; + + MutationCore::create_post(conn, form) + .await + .expect("could not insert post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully added".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +async fn edit_post( + Extension(ref templates): Extension, + Extension(ref conn): Extension, + Path(id): Path, +) -> Result, (StatusCode, &'static str)> { + let post: post::Model = QueryCore::find_post_by_id(conn, id) + .await + .expect("could not find post") + .unwrap_or_else(|| panic!("could not find post with id {}", id)); + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = templates + .render("edit.html.tera", &ctx) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; + + Ok(Html(body)) +} + +async fn update_post( + Extension(ref conn): Extension, + Path(id): Path, + form: Form, + mut cookies: Cookies, +) -> Result { + let form = form.0; + + MutationCore::update_post_by_id(conn, id, form) + .await + .expect("could not edit post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully updated".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +async fn delete_post( + Extension(ref conn): Extension, + Path(id): Path, + mut cookies: Cookies, +) -> Result { + MutationCore::delete_post(conn, id) + .await + .expect("could not delete post"); + + let data = FlashData { + kind: "success".to_owned(), + message: "Post succcessfully deleted".to_owned(), + }; + + Ok(post_response(&mut cookies, data)) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/axum_example/static/css/normalize.css b/examples/axum_example/api/static/css/normalize.css similarity index 100% rename from examples/axum_example/static/css/normalize.css rename to examples/axum_example/api/static/css/normalize.css diff --git a/examples/axum_example/static/css/skeleton.css b/examples/axum_example/api/static/css/skeleton.css similarity index 100% rename from examples/axum_example/static/css/skeleton.css rename to examples/axum_example/api/static/css/skeleton.css diff --git a/examples/axum_example/static/css/style.css b/examples/axum_example/api/static/css/style.css similarity index 100% rename from examples/axum_example/static/css/style.css rename to examples/axum_example/api/static/css/style.css diff --git a/examples/axum_example/static/images/favicon.png b/examples/axum_example/api/static/images/favicon.png similarity index 100% rename from examples/axum_example/static/images/favicon.png rename to examples/axum_example/api/static/images/favicon.png diff --git a/examples/axum_example/templates/edit.html.tera b/examples/axum_example/api/templates/edit.html.tera similarity index 100% rename from examples/axum_example/templates/edit.html.tera rename to examples/axum_example/api/templates/edit.html.tera diff --git a/examples/axum_example/templates/error/404.html.tera b/examples/axum_example/api/templates/error/404.html.tera similarity index 100% rename from examples/axum_example/templates/error/404.html.tera rename to examples/axum_example/api/templates/error/404.html.tera diff --git a/examples/axum_example/templates/index.html.tera b/examples/axum_example/api/templates/index.html.tera similarity index 100% rename from examples/axum_example/templates/index.html.tera rename to examples/axum_example/api/templates/index.html.tera diff --git a/examples/axum_example/templates/layout.html.tera b/examples/axum_example/api/templates/layout.html.tera similarity index 100% rename from examples/axum_example/templates/layout.html.tera rename to examples/axum_example/api/templates/layout.html.tera diff --git a/examples/axum_example/templates/new.html.tera b/examples/axum_example/api/templates/new.html.tera similarity index 100% rename from examples/axum_example/templates/new.html.tera rename to examples/axum_example/api/templates/new.html.tera diff --git a/examples/axum_example/core/Cargo.toml b/examples/axum_example/core/Cargo.toml new file mode 100644 index 00000000..2ba78874 --- /dev/null +++ b/examples/axum_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "axum-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/axum_example/core/src/lib.rs b/examples/axum_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/axum_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/axum_example/core/src/mutation.rs b/examples/axum_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/axum_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/axum_example/core/src/query.rs b/examples/axum_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/axum_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/axum_example/core/tests/mock.rs b/examples/axum_example/core/tests/mock.rs new file mode 100644 index 00000000..83210530 --- /dev/null +++ b/examples/axum_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use axum_example_core::{Mutation, Query}; +use entity::post; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/axum_example/core/tests/prepare.rs b/examples/axum_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/axum_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/axum_example/src/main.rs b/examples/axum_example/src/main.rs index 3669faca..e0b58d2e 100644 --- a/examples/axum_example/src/main.rs +++ b/examples/axum_example/src/main.rs @@ -1,220 +1,3 @@ -mod flash; - -use axum::{ - extract::{Extension, Form, Path, Query}, - http::StatusCode, - response::Html, - routing::{get, get_service, post}, - Router, Server, -}; -use entity::post; -use flash::{get_flash_cookie, post_response, PostResponse}; -use migration::{Migrator, MigratorTrait}; -use post::Entity as Post; -use sea_orm::{prelude::*, Database, QueryOrder, Set}; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; -use std::{env, net::SocketAddr}; -use tera::Tera; -use tower::ServiceBuilder; -use tower_cookies::{CookieManagerLayer, Cookies}; -use tower_http::services::ServeDir; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - let conn = Database::connect(db_url) - .await - .expect("Database connection failed"); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")) - .expect("Tera initialization failed"); - // let state = AppState { templates, conn }; - - let app = Router::new() - .route("/", get(list_posts).post(create_post)) - .route("/:id", get(edit_post).post(update_post)) - .route("/new", get(new_post)) - .route("/delete/:id", post(delete_post)) - .nest( - "/static", - get_service(ServeDir::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/static" - ))) - .handle_error(|error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }), - ) - .layer( - ServiceBuilder::new() - .layer(CookieManagerLayer::new()) - .layer(Extension(conn)) - .layer(Extension(templates)), - ); - - let addr = SocketAddr::from_str(&server_url).unwrap(); - Server::bind(&addr).serve(app.into_make_service()).await?; - - Ok(()) -} - -#[derive(Deserialize)] -struct Params { - page: Option, - posts_per_page: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -struct FlashData { - kind: String, - message: String, -} - -async fn list_posts( - Extension(ref templates): Extension, - Extension(ref conn): Extension, - Query(params): Query, - cookies: Cookies, -) -> Result, (StatusCode, &'static str)> { - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(5); - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(conn, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - if let Some(value) = get_flash_cookie::(&cookies) { - ctx.insert("flash", &value); - } - - let body = templates - .render("index.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn new_post( - Extension(ref templates): Extension, -) -> Result, (StatusCode, &'static str)> { - let ctx = tera::Context::new(); - let body = templates - .render("new.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn create_post( - Extension(ref conn): Extension, - form: Form, - mut cookies: Cookies, -) -> Result { - let model = form.0; - - post::ActiveModel { - title: Set(model.title.to_owned()), - text: Set(model.text.to_owned()), - ..Default::default() - } - .save(conn) - .await - .expect("could not insert post"); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully added".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) -} - -async fn edit_post( - Extension(ref templates): Extension, - Extension(ref conn): Extension, - Path(id): Path, -) -> Result, (StatusCode, &'static str)> { - let post: post::Model = Post::find_by_id(id) - .one(conn) - .await - .expect("could not find post") - .unwrap(); - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = templates - .render("edit.html.tera", &ctx) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Template error"))?; - - Ok(Html(body)) -} - -async fn update_post( - Extension(ref conn): Extension, - Path(id): Path, - form: Form, - mut cookies: Cookies, -) -> Result { - let model = form.0; - - post::ActiveModel { - id: Set(id), - title: Set(model.title.to_owned()), - text: Set(model.text.to_owned()), - } - .save(conn) - .await - .expect("could not edit post"); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully updated".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) -} - -async fn delete_post( - Extension(ref conn): Extension, - Path(id): Path, - mut cookies: Cookies, -) -> Result { - let post: post::ActiveModel = Post::find_by_id(id) - .one(conn) - .await - .unwrap() - .unwrap() - .into(); - - post.delete(conn).await.unwrap(); - - let data = FlashData { - kind: "success".to_owned(), - message: "Post succcessfully deleted".to_owned(), - }; - - Ok(post_response(&mut cookies, data)) +fn main() { + axum_example_api::main(); } diff --git a/examples/graphql_example/Cargo.toml b/examples/graphql_example/Cargo.toml index 107de341..c3e342dc 100644 --- a/examples/graphql_example/Cargo.toml +++ b/examples/graphql_example/Cargo.toml @@ -7,22 +7,7 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.0", features = ["full"] } -axum = "^0.5.1" -dotenv = "0.15.0" -async-graphql-axum = "^4.0.6" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "runtime-tokio-native-tls", - # "sqlx-postgres", - # "sqlx-mysql", - "sqlx-sqlite" -] +graphql-example-api = { path = "api" } diff --git a/examples/graphql_example/README.md b/examples/graphql_example/README.md index 88501958..351c7df5 100644 --- a/examples/graphql_example/README.md +++ b/examples/graphql_example/README.md @@ -6,8 +6,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:3000/api/graphql](http://localhost:3000/api/graphql) in browser + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/graphql_example/api/Cargo.toml b/examples/graphql_example/api/Cargo.toml new file mode 100644 index 00000000..e4526410 --- /dev/null +++ b/examples/graphql_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "graphql-example-api" +authors = ["Aaron Leopold "] +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +graphql-example-core = { path = "../core" } +tokio = { version = "1.0", features = ["full"] } +axum = "^0.5.1" +dotenv = "0.15.0" +async-graphql-axum = "^4.0.6" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/graphql_example/api/src/db.rs b/examples/graphql_example/api/src/db.rs new file mode 100644 index 00000000..a1b79cd4 --- /dev/null +++ b/examples/graphql_example/api/src/db.rs @@ -0,0 +1,21 @@ +use graphql_example_core::sea_orm::DatabaseConnection; + +pub struct Database { + pub connection: DatabaseConnection, +} + +impl Database { + pub async fn new() -> Self { + let connection = graphql_example_core::sea_orm::Database::connect( + std::env::var("DATABASE_URL").unwrap(), + ) + .await + .expect("Could not connect to database"); + + Database { connection } + } + + pub fn get_connection(&self) -> &DatabaseConnection { + &self.connection + } +} diff --git a/examples/graphql_example/src/graphql/mod.rs b/examples/graphql_example/api/src/graphql/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/mod.rs rename to examples/graphql_example/api/src/graphql/mod.rs diff --git a/examples/graphql_example/src/graphql/mutation/mod.rs b/examples/graphql_example/api/src/graphql/mutation/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/mutation/mod.rs rename to examples/graphql_example/api/src/graphql/mutation/mod.rs diff --git a/examples/graphql_example/src/graphql/mutation/note.rs b/examples/graphql_example/api/src/graphql/mutation/note.rs similarity index 68% rename from examples/graphql_example/src/graphql/mutation/note.rs rename to examples/graphql_example/api/src/graphql/mutation/note.rs index 600b462b..a3ce9f48 100644 --- a/examples/graphql_example/src/graphql/mutation/note.rs +++ b/examples/graphql_example/api/src/graphql/mutation/note.rs @@ -1,7 +1,7 @@ use async_graphql::{Context, Object, Result}; use entity::async_graphql::{self, InputObject, SimpleObject}; use entity::note; -use sea_orm::{ActiveModelTrait, Set}; +use graphql_example_core::Mutation; use crate::db::Database; @@ -14,6 +14,16 @@ pub struct CreateNoteInput { pub text: String, } +impl CreateNoteInput { + fn into_model_with_arbitrary_id(self) -> note::Model { + note::Model { + id: 0, + title: self.title, + text: self.text, + } + } +} + #[derive(SimpleObject)] pub struct DeleteResult { pub success: bool, @@ -31,22 +41,18 @@ impl NoteMutation { input: CreateNoteInput, ) -> Result { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - let note = note::ActiveModel { - title: Set(input.title), - text: Set(input.text), - ..Default::default() - }; - - Ok(note.insert(db.get_connection()).await?) + Ok(Mutation::create_note(conn, input.into_model_with_arbitrary_id()).await?) } pub async fn delete_note(&self, ctx: &Context<'_>, id: i32) -> Result { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - let res = note::Entity::delete_by_id(id) - .exec(db.get_connection()) - .await?; + let res = Mutation::delete_note(conn, id) + .await + .expect("Cannot delete note"); if res.rows_affected <= 1 { Ok(DeleteResult { diff --git a/examples/graphql_example/src/graphql/query/mod.rs b/examples/graphql_example/api/src/graphql/query/mod.rs similarity index 100% rename from examples/graphql_example/src/graphql/query/mod.rs rename to examples/graphql_example/api/src/graphql/query/mod.rs diff --git a/examples/graphql_example/src/graphql/query/note.rs b/examples/graphql_example/api/src/graphql/query/note.rs similarity index 75% rename from examples/graphql_example/src/graphql/query/note.rs rename to examples/graphql_example/api/src/graphql/query/note.rs index 696d4720..1ac2549d 100644 --- a/examples/graphql_example/src/graphql/query/note.rs +++ b/examples/graphql_example/api/src/graphql/query/note.rs @@ -1,6 +1,6 @@ use async_graphql::{Context, Object, Result}; use entity::{async_graphql, note}; -use sea_orm::EntityTrait; +use graphql_example_core::Query; use crate::db::Database; @@ -11,18 +11,18 @@ pub struct NoteQuery; impl NoteQuery { async fn get_notes(&self, ctx: &Context<'_>) -> Result> { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - Ok(note::Entity::find() - .all(db.get_connection()) + Ok(Query::get_all_notes(conn) .await .map_err(|e| e.to_string())?) } async fn get_note_by_id(&self, ctx: &Context<'_>, id: i32) -> Result> { let db = ctx.data::().unwrap(); + let conn = db.get_connection(); - Ok(note::Entity::find_by_id(id) - .one(db.get_connection()) + Ok(Query::find_note_by_id(conn, id) .await .map_err(|e| e.to_string())?) } diff --git a/examples/graphql_example/src/graphql/schema.rs b/examples/graphql_example/api/src/graphql/schema.rs similarity index 100% rename from examples/graphql_example/src/graphql/schema.rs rename to examples/graphql_example/api/src/graphql/schema.rs diff --git a/examples/graphql_example/api/src/lib.rs b/examples/graphql_example/api/src/lib.rs new file mode 100644 index 00000000..42e63e1c --- /dev/null +++ b/examples/graphql_example/api/src/lib.rs @@ -0,0 +1,49 @@ +mod db; +mod graphql; + +use entity::async_graphql; + +use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; +use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; +use axum::{ + extract::Extension, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use graphql::schema::{build_schema, AppSchema}; + +#[cfg(debug_assertions)] +use dotenv::dotenv; + +async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { + schema.execute(req.into_inner()).await.into() +} + +async fn graphql_playground() -> impl IntoResponse { + Html(playground_source(GraphQLPlaygroundConfig::new( + "/api/graphql", + ))) +} + +#[tokio::main] +pub async fn main() { + #[cfg(debug_assertions)] + dotenv().ok(); + + let schema = build_schema().await; + + let app = Router::new() + .route( + "/api/graphql", + get(graphql_playground).post(graphql_handler), + ) + .layer(Extension(schema)); + + println!("Playground: http://localhost:3000/api/graphql"); + + axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} diff --git a/examples/graphql_example/core/Cargo.toml b/examples/graphql_example/core/Cargo.toml new file mode 100644 index 00000000..739d4b38 --- /dev/null +++ b/examples/graphql_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "graphql-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + # "sqlx-postgres", + # "sqlx-mysql", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/graphql_example/core/src/lib.rs b/examples/graphql_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/graphql_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/graphql_example/core/src/mutation.rs b/examples/graphql_example/core/src/mutation.rs new file mode 100644 index 00000000..1f2447fb --- /dev/null +++ b/examples/graphql_example/core/src/mutation.rs @@ -0,0 +1,54 @@ +use ::entity::{note, note::Entity as Note}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_note(db: &DbConn, form_data: note::Model) -> Result { + let active_model = note::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + }; + let res = Note::insert(active_model).exec(db).await?; + + Ok(note::Model { + id: res.last_insert_id, + ..form_data + }) + } + + pub async fn update_note_by_id( + db: &DbConn, + id: i32, + form_data: note::Model, + ) -> Result { + let note: note::ActiveModel = Note::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find note.".to_owned())) + .map(Into::into)?; + + note::ActiveModel { + id: note.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_note(db: &DbConn, id: i32) -> Result { + let note: note::ActiveModel = Note::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find note.".to_owned())) + .map(Into::into)?; + + note.delete(db).await + } + + pub async fn delete_all_notes(db: &DbConn) -> Result { + Note::delete_many().exec(db).await + } +} diff --git a/examples/graphql_example/core/src/query.rs b/examples/graphql_example/core/src/query.rs new file mode 100644 index 00000000..7b6dc1b7 --- /dev/null +++ b/examples/graphql_example/core/src/query.rs @@ -0,0 +1,30 @@ +use ::entity::{note, note::Entity as Note}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_note_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Note::find_by_id(id).one(db).await + } + + pub async fn get_all_notes(db: &DbConn) -> Result, DbErr> { + Note::find().all(db).await + } + + /// If ok, returns (note models, num pages). + pub async fn find_notes_in_page( + db: &DbConn, + page: u64, + notes_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Note::find() + .order_by_asc(note::Column::Id) + .paginate(db, notes_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated notes + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/graphql_example/core/tests/mock.rs b/examples/graphql_example/core/tests/mock.rs new file mode 100644 index 00000000..16a56189 --- /dev/null +++ b/examples/graphql_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::note; +use graphql_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let note = Query::find_note_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(note.id, 1); + } + + { + let note = Query::find_note_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(note.id, 5); + } + + { + let note = Mutation::create_note( + db, + note::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + note, + note::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + } + ); + } + + { + let note = Mutation::update_note_by_id( + db, + 1, + note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + note, + note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_note(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_notes(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/graphql_example/core/tests/prepare.rs b/examples/graphql_example/core/tests/prepare.rs new file mode 100644 index 00000000..fd55936d --- /dev/null +++ b/examples/graphql_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::note; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![note::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![note::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![note::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![note::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![note::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![note::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/graphql_example/src/db.rs b/examples/graphql_example/src/db.rs deleted file mode 100644 index 3cc41e27..00000000 --- a/examples/graphql_example/src/db.rs +++ /dev/null @@ -1,19 +0,0 @@ -use sea_orm::DatabaseConnection; - -pub struct Database { - pub connection: DatabaseConnection, -} - -impl Database { - pub async fn new() -> Self { - let connection = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap()) - .await - .expect("Could not connect to database"); - - Database { connection } - } - - pub fn get_connection(&self) -> &DatabaseConnection { - &self.connection - } -} diff --git a/examples/graphql_example/src/main.rs b/examples/graphql_example/src/main.rs index 665f79f2..308b656a 100644 --- a/examples/graphql_example/src/main.rs +++ b/examples/graphql_example/src/main.rs @@ -1,49 +1,3 @@ -mod db; -mod graphql; - -use entity::async_graphql; - -use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; -use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; -use axum::{ - extract::Extension, - response::{Html, IntoResponse}, - routing::get, - Router, -}; -use graphql::schema::{build_schema, AppSchema}; - -#[cfg(debug_assertions)] -use dotenv::dotenv; - -async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { - schema.execute(req.into_inner()).await.into() -} - -async fn graphql_playground() -> impl IntoResponse { - Html(playground_source(GraphQLPlaygroundConfig::new( - "/api/graphql", - ))) -} - -#[tokio::main] -async fn main() { - #[cfg(debug_assertions)] - dotenv().ok(); - - let schema = build_schema().await; - - let app = Router::new() - .route( - "/api/graphql", - get(graphql_playground).post(graphql_handler), - ) - .layer(Extension(schema)); - - println!("Playground: http://localhost:3000/api/graphql"); - - axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); +fn main() { + graphql_example_api::main(); } diff --git a/examples/jsonrpsee_example/Cargo.toml b/examples/jsonrpsee_example/Cargo.toml index d507a5c6..7365aa55 100644 --- a/examples/jsonrpsee_example/Cargo.toml +++ b/examples/jsonrpsee_example/Cargo.toml @@ -6,28 +6,7 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -jsonrpsee = { version = "^0.8.0", features = ["full"] } -jsonrpsee-core = "0.9.0" -tokio = { version = "1.8.0", features = ["full"] } -serde = { version = "1", features = ["derive"] } -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } -anyhow = "1.0.52" -async-trait = "0.1.52" -log = { version = "0.4", features = ["std"] } -simplelog = "*" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +jsonrpsee-example-api = { path = "api" } diff --git a/examples/jsonrpsee_example/README.md b/examples/jsonrpsee_example/README.md index 80489331..4d45e5b6 100644 --- a/examples/jsonrpsee_example/README.md +++ b/examples/jsonrpsee_example/README.md @@ -2,11 +2,11 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server -2. Send jsonrpc request to server +1. Send jsonrpc request to server ```shell #insert @@ -20,7 +20,7 @@ curl --location --request POST 'http://127.0.0.1:8000' \ } ], "id": 2}' -#list +#list curl --location --request POST 'http://127.0.0.1:8000' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -33,7 +33,7 @@ curl --location --request POST 'http://127.0.0.1:8000' \ "id": 2 }' -#delete +#delete curl --location --request POST 'http://127.0.0.1:8000' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -61,4 +61,11 @@ curl --location --request POST 'http://127.0.0.1:8000' \ "id": 2 }' -``` \ No newline at end of file +``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/jsonrpsee_example/api/Cargo.toml b/examples/jsonrpsee_example/api/Cargo.toml new file mode 100644 index 00000000..51c959e6 --- /dev/null +++ b/examples/jsonrpsee_example/api/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jsonrpsee-example-api" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +jsonrpsee-example-core = { path = "../core" } +jsonrpsee = { version = "^0.8.0", features = ["full"] } +jsonrpsee-core = "0.9.0" +tokio = { version = "1.8.0", features = ["full"] } +serde = { version = "1", features = ["derive"] } +dotenv = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } +anyhow = "1.0.52" +async-trait = "0.1.52" +log = { version = "0.4", features = ["std"] } +simplelog = "*" diff --git a/examples/jsonrpsee_example/api/src/lib.rs b/examples/jsonrpsee_example/api/src/lib.rs new file mode 100644 index 00000000..f98cc183 --- /dev/null +++ b/examples/jsonrpsee_example/api/src/lib.rs @@ -0,0 +1,143 @@ +use std::env; + +use anyhow::anyhow; +use entity::post; +use jsonrpsee::core::{async_trait, RpcResult}; +use jsonrpsee::http_server::HttpServerBuilder; +use jsonrpsee::proc_macros::rpc; +use jsonrpsee::types::error::CallError; +use jsonrpsee_example_core::sea_orm::{Database, DatabaseConnection}; +use jsonrpsee_example_core::{Mutation, Query}; +use log::info; +use migration::{Migrator, MigratorTrait}; +use simplelog::*; +use std::fmt::Display; +use std::net::SocketAddr; +use tokio::signal::ctrl_c; +use tokio::signal::unix::{signal, SignalKind}; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[rpc(server, client)] +trait PostRpc { + #[method(name = "Post.List")] + async fn list( + &self, + page: Option, + posts_per_page: Option, + ) -> RpcResult>; + + #[method(name = "Post.Insert")] + async fn insert(&self, p: post::Model) -> RpcResult; + + #[method(name = "Post.Update")] + async fn update(&self, p: post::Model) -> RpcResult; + + #[method(name = "Post.Delete")] + async fn delete(&self, id: i32) -> RpcResult; +} + +struct PpcImpl { + conn: DatabaseConnection, +} + +#[async_trait] +impl PostRpcServer for PpcImpl { + async fn list( + &self, + page: Option, + posts_per_page: Option, + ) -> RpcResult> { + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + Query::find_posts_in_page(&self.conn, page, posts_per_page) + .await + .map(|(p, _)| p) + .internal_call_error() + } + + async fn insert(&self, p: post::Model) -> RpcResult { + let new_post = Mutation::create_post(&self.conn, p) + .await + .internal_call_error()?; + + Ok(new_post.id.unwrap()) + } + + async fn update(&self, p: post::Model) -> RpcResult { + Mutation::update_post_by_id(&self.conn, p.id, p) + .await + .map(|_| true) + .internal_call_error() + } + async fn delete(&self, id: i32) -> RpcResult { + Mutation::delete_post(&self.conn, id) + .await + .map(|res| res.rows_affected == 1) + .internal_call_error() + } +} + +trait IntoJsonRpcResult { + fn internal_call_error(self) -> RpcResult; +} + +impl IntoJsonRpcResult for Result +where + E: Display, +{ + fn internal_call_error(self) -> RpcResult { + self.map_err(|e| jsonrpsee::core::Error::Call(CallError::Failed(anyhow!("{}", e)))) + } +} + +#[tokio::main] +async fn start() -> std::io::Result<()> { + let _ = TermLogger::init( + LevelFilter::Trace, + Config::default(), + TerminalMode::Mixed, + ColorChoice::Auto, + ); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + + let server = HttpServerBuilder::default() + .build(server_url.parse::().unwrap()) + .unwrap(); + + let rpc_impl = PpcImpl { conn }; + let server_addr = server.local_addr().unwrap(); + let handle = server.start(rpc_impl.into_rpc()).unwrap(); + + info!("starting listening {}", server_addr); + let mut sig_int = signal(SignalKind::interrupt()).unwrap(); + let mut sig_term = signal(SignalKind::terminate()).unwrap(); + + tokio::select! { + _ = sig_int.recv() => info!("receive SIGINT"), + _ = sig_term.recv() => info!("receive SIGTERM"), + _ = ctrl_c() => info!("receive Ctrl C"), + } + handle.stop().unwrap(); + info!("Shutdown program"); + Ok(()) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/jsonrpsee_example/core/Cargo.toml b/examples/jsonrpsee_example/core/Cargo.toml new file mode 100644 index 00000000..31ebbfcc --- /dev/null +++ b/examples/jsonrpsee_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "jsonrpsee-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-native-tls", + "sqlx-sqlite", + # "sqlx-postgres", + # "sqlx-mysql", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/jsonrpsee_example/core/src/lib.rs b/examples/jsonrpsee_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/jsonrpsee_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/jsonrpsee_example/core/src/mutation.rs b/examples/jsonrpsee_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/jsonrpsee_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/jsonrpsee_example/core/src/query.rs b/examples/jsonrpsee_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/jsonrpsee_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/jsonrpsee_example/core/tests/mock.rs b/examples/jsonrpsee_example/core/tests/mock.rs new file mode 100644 index 00000000..068f31b6 --- /dev/null +++ b/examples/jsonrpsee_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use jsonrpsee_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/jsonrpsee_example/core/tests/prepare.rs b/examples/jsonrpsee_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/jsonrpsee_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/jsonrpsee_example/src/main.rs b/examples/jsonrpsee_example/src/main.rs index e1d26f32..2625a68d 100644 --- a/examples/jsonrpsee_example/src/main.rs +++ b/examples/jsonrpsee_example/src/main.rs @@ -1,148 +1,3 @@ -use std::env; - -use anyhow::anyhow; -use entity::post; -use jsonrpsee::core::{async_trait, RpcResult}; -use jsonrpsee::http_server::HttpServerBuilder; -use jsonrpsee::proc_macros::rpc; -use jsonrpsee::types::error::CallError; -use log::info; -use migration::{Migrator, MigratorTrait}; -use sea_orm::NotSet; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use simplelog::*; -use std::fmt::Display; -use std::net::SocketAddr; -use tokio::signal::ctrl_c; -use tokio::signal::unix::{signal, SignalKind}; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[rpc(server, client)] -pub trait PostRpc { - #[method(name = "Post.List")] - async fn list( - &self, - page: Option, - posts_per_page: Option, - ) -> RpcResult>; - - #[method(name = "Post.Insert")] - async fn insert(&self, p: post::Model) -> RpcResult; - - #[method(name = "Post.Update")] - async fn update(&self, p: post::Model) -> RpcResult; - - #[method(name = "Post.Delete")] - async fn delete(&self, id: i32) -> RpcResult; -} - -pub struct PpcImpl { - conn: DatabaseConnection, -} - -#[async_trait] -impl PostRpcServer for PpcImpl { - async fn list( - &self, - page: Option, - posts_per_page: Option, - ) -> RpcResult> { - let page = page.unwrap_or(1); - let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&self.conn, posts_per_page); - paginator.fetch_page(page - 1).await.internal_call_error() - } - - async fn insert(&self, p: post::Model) -> RpcResult { - let active_post = post::ActiveModel { - id: NotSet, - title: Set(p.title), - text: Set(p.text), - }; - let new_post = active_post.insert(&self.conn).await.internal_call_error()?; - Ok(new_post.id) - } - - async fn update(&self, p: post::Model) -> RpcResult { - let update_post = post::ActiveModel { - id: Set(p.id), - title: Set(p.title), - text: Set(p.text), - }; - update_post - .update(&self.conn) - .await - .map(|_| true) - .internal_call_error() - } - async fn delete(&self, id: i32) -> RpcResult { - let post = post::Entity::find_by_id(id) - .one(&self.conn) - .await - .internal_call_error()?; - - post.unwrap() - .delete(&self.conn) - .await - .map(|res| res.rows_affected == 1) - .internal_call_error() - } -} - -pub trait IntoJsonRpcResult { - fn internal_call_error(self) -> RpcResult; -} - -impl IntoJsonRpcResult for Result -where - E: Display, -{ - fn internal_call_error(self) -> RpcResult { - self.map_err(|e| jsonrpsee::core::Error::Call(CallError::Failed(anyhow!("{}", e)))) - } -} - -#[tokio::main] -async fn main() -> std::io::Result<()> { - let _ = TermLogger::init( - LevelFilter::Trace, - Config::default(), - TerminalMode::Mixed, - ColorChoice::Auto, - ); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - - let server = HttpServerBuilder::default() - .build(server_url.parse::().unwrap()) - .unwrap(); - - let rpc_impl = PpcImpl { conn }; - let server_addr = server.local_addr().unwrap(); - let handle = server.start(rpc_impl.into_rpc()).unwrap(); - - info!("starting listening {}", server_addr); - let mut sig_int = signal(SignalKind::interrupt()).unwrap(); - let mut sig_term = signal(SignalKind::terminate()).unwrap(); - - tokio::select! { - _ = sig_int.recv() => info!("receive SIGINT"), - _ = sig_term.recv() => info!("receive SIGTERM"), - _ = ctrl_c() => info!("receive Ctrl C"), - } - handle.stop().unwrap(); - info!("Shutdown program"); - Ok(()) +fn main() { + jsonrpsee_example_api::main(); } diff --git a/examples/poem_example/Cargo.toml b/examples/poem_example/Cargo.toml index 0139a5da..9024d729 100644 --- a/examples/poem_example/Cargo.toml +++ b/examples/poem_example/Cargo.toml @@ -5,25 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } -poem = { version = "1.2.33", features = ["static-files"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -tera = "1.8.0" -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +poem-example-api = { path = "api" } diff --git a/examples/poem_example/README.md b/examples/poem_example/README.md index bd4a4539..d0aa6973 100644 --- a/examples/poem_example/README.md +++ b/examples/poem_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `server started` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/poem_example/api/Cargo.toml b/examples/poem_example/api/Cargo.toml new file mode 100644 index 00000000..379bc181 --- /dev/null +++ b/examples/poem_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "poem-example-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +poem-example-core = { path = "../core" } +tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } +poem = { version = "1.2.33", features = ["static-files"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +tera = "1.8.0" +dotenv = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/poem_example/api/src/lib.rs b/examples/poem_example/api/src/lib.rs new file mode 100644 index 00000000..3dc8fb03 --- /dev/null +++ b/examples/poem_example/api/src/lib.rs @@ -0,0 +1,163 @@ +use std::env; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use poem::endpoint::StaticFilesEndpoint; +use poem::error::InternalServerError; +use poem::http::StatusCode; +use poem::listener::TcpListener; +use poem::web::{Data, Form, Html, Path, Query}; +use poem::{get, handler, post, EndpointExt, Error, IntoResponse, Result, Route, Server}; +use poem_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation as MutationCore, Query as QueryCore, +}; +use serde::Deserialize; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[derive(Deserialize)] +struct Params { + page: Option, + posts_per_page: Option, +} + +#[handler] +async fn create(state: Data<&AppState>, form: Form) -> Result { + let form = form.0; + let conn = &state.conn; + + MutationCore::create_post(conn, form) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[handler] +async fn list(state: Data<&AppState>, Query(params): Query) -> Result { + let conn = &state.conn; + let page = params.page.unwrap_or(1); + let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = QueryCore::find_posts_in_page(conn, page, posts_per_page) + .await + .map_err(InternalServerError)?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = state + .templates + .render("index.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn new(state: Data<&AppState>) -> Result { + let ctx = tera::Context::new(); + let body = state + .templates + .render("new.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn edit(state: Data<&AppState>, Path(id): Path) -> Result { + let conn = &state.conn; + + let post: post::Model = QueryCore::find_post_by_id(conn, id) + .await + .map_err(InternalServerError)? + .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = state + .templates + .render("edit.html.tera", &ctx) + .map_err(InternalServerError)?; + Ok(Html(body)) +} + +#[handler] +async fn update( + state: Data<&AppState>, + Path(id): Path, + form: Form, +) -> Result { + let conn = &state.conn; + let form = form.0; + + MutationCore::update_post_by_id(conn, id, form) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[handler] +async fn delete(state: Data<&AppState>, Path(id): Path) -> Result { + let conn = &state.conn; + + MutationCore::delete_post(conn, id) + .await + .map_err(InternalServerError)?; + + Ok(StatusCode::FOUND.with_header("location", "/")) +} + +#[tokio::main] +async fn start() -> std::io::Result<()> { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + println!("Starting server at {}", server_url); + + let app = Route::new() + .at("/", post(create).get(list)) + .at("/new", new) + .at("/:id", get(edit).post(update)) + .at("/delete/:id", post(delete)) + .nest( + "/static", + StaticFilesEndpoint::new(concat!(env!("CARGO_MANIFEST_DIR"), "/static")), + ) + .data(state); + let server = Server::new(TcpListener::bind(format!("{}:{}", host, port))); + server.run(app).await +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/poem_example/static/css/normalize.css b/examples/poem_example/api/static/css/normalize.css similarity index 100% rename from examples/poem_example/static/css/normalize.css rename to examples/poem_example/api/static/css/normalize.css diff --git a/examples/poem_example/static/css/skeleton.css b/examples/poem_example/api/static/css/skeleton.css similarity index 100% rename from examples/poem_example/static/css/skeleton.css rename to examples/poem_example/api/static/css/skeleton.css diff --git a/examples/poem_example/static/css/style.css b/examples/poem_example/api/static/css/style.css similarity index 100% rename from examples/poem_example/static/css/style.css rename to examples/poem_example/api/static/css/style.css diff --git a/examples/poem_example/static/images/favicon.png b/examples/poem_example/api/static/images/favicon.png similarity index 100% rename from examples/poem_example/static/images/favicon.png rename to examples/poem_example/api/static/images/favicon.png diff --git a/examples/poem_example/templates/edit.html.tera b/examples/poem_example/api/templates/edit.html.tera similarity index 100% rename from examples/poem_example/templates/edit.html.tera rename to examples/poem_example/api/templates/edit.html.tera diff --git a/examples/poem_example/templates/error/404.html.tera b/examples/poem_example/api/templates/error/404.html.tera similarity index 100% rename from examples/poem_example/templates/error/404.html.tera rename to examples/poem_example/api/templates/error/404.html.tera diff --git a/examples/poem_example/templates/index.html.tera b/examples/poem_example/api/templates/index.html.tera similarity index 100% rename from examples/poem_example/templates/index.html.tera rename to examples/poem_example/api/templates/index.html.tera diff --git a/examples/poem_example/templates/layout.html.tera b/examples/poem_example/api/templates/layout.html.tera similarity index 100% rename from examples/poem_example/templates/layout.html.tera rename to examples/poem_example/api/templates/layout.html.tera diff --git a/examples/poem_example/templates/new.html.tera b/examples/poem_example/api/templates/new.html.tera similarity index 100% rename from examples/poem_example/templates/new.html.tera rename to examples/poem_example/api/templates/new.html.tera diff --git a/examples/poem_example/core/Cargo.toml b/examples/poem_example/core/Cargo.toml new file mode 100644 index 00000000..e7b64f35 --- /dev/null +++ b/examples/poem_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "poem-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-async-std-native-tls", + # "sqlx-mysql", + # "sqlx-postgres", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/poem_example/core/src/lib.rs b/examples/poem_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/poem_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/poem_example/core/src/mutation.rs b/examples/poem_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/poem_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/poem_example/core/src/query.rs b/examples/poem_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/poem_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/poem_example/core/tests/mock.rs b/examples/poem_example/core/tests/mock.rs new file mode 100644 index 00000000..e4b3ef4f --- /dev/null +++ b/examples/poem_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use poem_example_core::{Mutation, Query}; +use prepare::prepare_mock_db; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/poem_example/core/tests/prepare.rs b/examples/poem_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/poem_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/poem_example/src/main.rs b/examples/poem_example/src/main.rs index 14c7c3f4..e3c79da1 100644 --- a/examples/poem_example/src/main.rs +++ b/examples/poem_example/src/main.rs @@ -1,162 +1,3 @@ -use std::env; - -use entity::post; -use migration::{Migrator, MigratorTrait}; -use poem::endpoint::StaticFilesEndpoint; -use poem::error::{BadRequest, InternalServerError}; -use poem::http::StatusCode; -use poem::listener::TcpListener; -use poem::web::{Data, Form, Html, Path, Query}; -use poem::{get, handler, post, EndpointExt, Error, IntoResponse, Result, Route, Server}; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use serde::Deserialize; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[derive(Deserialize)] -struct Params { - page: Option, - posts_per_page: Option, -} - -#[handler] -async fn create(state: Data<&AppState>, form: Form) -> Result { - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[handler] -async fn list(state: Data<&AppState>, Query(params): Query) -> Result { - let page = params.page.unwrap_or(1); - let posts_per_page = params.posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&state.conn, posts_per_page); - let num_pages = paginator.num_pages().await.map_err(BadRequest)?; - let posts = paginator - .fetch_page(page - 1) - .await - .map_err(InternalServerError)?; - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = state - .templates - .render("index.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn new(state: Data<&AppState>) -> Result { - let ctx = tera::Context::new(); - let body = state - .templates - .render("new.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn edit(state: Data<&AppState>, Path(id): Path) -> Result { - let post: post::Model = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(InternalServerError)? - .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))?; - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = state - .templates - .render("edit.html.tera", &ctx) - .map_err(InternalServerError)?; - Ok(Html(body)) -} - -#[handler] -async fn update( - state: Data<&AppState>, - Path(id): Path, - form: Form, -) -> Result { - post::ActiveModel { - id: Set(id), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[handler] -async fn delete(state: Data<&AppState>, Path(id): Path) -> Result { - let post: post::ActiveModel = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(InternalServerError)? - .ok_or_else(|| Error::from_status(StatusCode::NOT_FOUND))? - .into(); - post.delete(&state.conn) - .await - .map_err(InternalServerError)?; - - Ok(StatusCode::FOUND.with_header("location", "/")) -} - -#[tokio::main] -async fn main() -> std::io::Result<()> { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - println!("Starting server at {}", server_url); - - let app = Route::new() - .at("/", post(create).get(list)) - .at("/new", new) - .at("/:id", get(edit).post(update)) - .at("/delete/:id", post(delete)) - .nest( - "/static", - StaticFilesEndpoint::new(concat!(env!("CARGO_MANIFEST_DIR"), "/static")), - ) - .data(state); - let server = Server::new(TcpListener::bind(format!("{}:{}", host, port))); - server.run(app).await +fn main() { + poem_example_api::main(); } diff --git a/examples/rocket_example/Cargo.toml b/examples/rocket_example/Cargo.toml index e16a7aeb..992ad036 100644 --- a/examples/rocket_example/Cargo.toml +++ b/examples/rocket_example/Cargo.toml @@ -6,33 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -async-stream = { version = "^0.3" } -async-trait = { version = "0.1" } -futures = { version = "^0.3" } -futures-util = { version = "^0.3" } -rocket = { version = "0.5.0-rc.1", features = [ - "json", -] } -rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ - "tera", -] } -serde_json = { version = "^1" } -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm-rocket] -path = "../../sea-orm-rocket/lib" # remove this line in your own project and use the git line -# git = "https://github.com/SeaQL/sea-orm" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "runtime-tokio-native-tls", - "sqlx-postgres", - # "sqlx-mysql", - # "sqlx-sqlite", -] +rocket-example-api = { path = "api" } diff --git a/examples/rocket_example/README.md b/examples/rocket_example/README.md index a1e3af0f..20845db6 100644 --- a/examples/rocket_example/README.md +++ b/examples/rocket_example/README.md @@ -2,10 +2,17 @@ # Rocket with SeaORM example app -1. Modify the `url` var in `Rocket.toml` to point to your chosen database +1. Modify the `url` var in `api/Rocket.toml` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-postgres",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-postgres",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `🚀 Rocket has launched from http://localhost:8000` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/rocket_example/Rocket.toml b/examples/rocket_example/Rocket.toml index fc294bd2..41e0183c 100644 --- a/examples/rocket_example/Rocket.toml +++ b/examples/rocket_example/Rocket.toml @@ -1,5 +1,5 @@ [default] -template_dir = "templates/" +template_dir = "api/templates/" [default.databases.sea_orm] # Mysql diff --git a/examples/rocket_example/api/Cargo.toml b/examples/rocket_example/api/Cargo.toml new file mode 100644 index 00000000..1a88308f --- /dev/null +++ b/examples/rocket_example/api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "rocket-example-api" +version = "0.1.0" +authors = ["Sam Samai "] +edition = "2021" +publish = false + +[dependencies] +async-stream = { version = "^0.3" } +async-trait = { version = "0.1" } +rocket-example-core = { path = "../core" } +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } +rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ + "tera", +] } +serde_json = { version = "^1" } +entity = { path = "../entity" } +migration = { path = "../migration" } +tokio = "1.20.0" + +[dependencies.sea-orm-rocket] +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the git line +# git = "https://github.com/SeaQL/sea-orm" diff --git a/examples/rocket_example/api/src/lib.rs b/examples/rocket_example/api/src/lib.rs new file mode 100644 index 00000000..51918c7a --- /dev/null +++ b/examples/rocket_example/api/src/lib.rs @@ -0,0 +1,171 @@ +#[macro_use] +extern crate rocket; + +use rocket::fairing::{self, AdHoc}; +use rocket::form::{Context, Form}; +use rocket::fs::{relative, FileServer}; +use rocket::request::FlashMessage; +use rocket::response::{Flash, Redirect}; +use rocket::{Build, Request, Rocket}; +use rocket_dyn_templates::Template; +use rocket_example_core::{Mutation, Query}; +use serde_json::json; + +use migration::MigratorTrait; +use sea_orm_rocket::{Connection, Database}; + +mod pool; +use pool::Db; + +pub use entity::post; +pub use entity::post::Entity as Post; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +#[get("/new")] +async fn new() -> Template { + Template::render("new", &Context::default()) +} + +#[post("/", data = "")] +async fn create(conn: Connection<'_, Db>, post_form: Form) -> Flash { + let db = conn.into_inner(); + + let form = post_form.into_inner(); + + Mutation::create_post(db, form) + .await + .expect("could not insert post"); + + Flash::success(Redirect::to("/"), "Post successfully added.") +} + +#[post("/", data = "")] +async fn update( + conn: Connection<'_, Db>, + id: i32, + post_form: Form, +) -> Flash { + let db = conn.into_inner(); + + let form = post_form.into_inner(); + + Mutation::update_post_by_id(db, id, form) + .await + .expect("could not update post"); + + Flash::success(Redirect::to("/"), "Post successfully edited.") +} + +#[get("/?&")] +async fn list( + conn: Connection<'_, Db>, + page: Option, + posts_per_page: Option, + flash: Option>, +) -> Template { + let db = conn.into_inner(); + + // Set page number and items per page + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + if page == 0 { + panic!("Page number cannot be zero"); + } + + let (posts, num_pages) = Query::find_posts_in_page(db, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + Template::render( + "index", + json! ({ + "page": page, + "posts_per_page": posts_per_page, + "num_pages": num_pages, + "posts": posts, + "flash": flash.map(FlashMessage::into_inner), + }), + ) +} + +#[get("/")] +async fn edit(conn: Connection<'_, Db>, id: i32) -> Template { + let db = conn.into_inner(); + + let post: Option = Query::find_post_by_id(db, id) + .await + .expect("could not find post"); + + Template::render( + "edit", + json! ({ + "post": post, + }), + ) +} + +#[delete("/")] +async fn delete(conn: Connection<'_, Db>, id: i32) -> Flash { + let db = conn.into_inner(); + + Mutation::delete_post(db, id) + .await + .expect("could not delete post"); + + Flash::success(Redirect::to("/"), "Post successfully deleted.") +} + +#[delete("/")] +async fn destroy(conn: Connection<'_, Db>) -> Result<(), rocket::response::Debug> { + let db = conn.into_inner(); + + Mutation::delete_all_posts(db) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[catch(404)] +pub fn not_found(req: &Request<'_>) -> Template { + Template::render( + "error/404", + json! ({ + "uri": req.uri() + }), + ) +} + +async fn run_migrations(rocket: Rocket) -> fairing::Result { + let conn = &Db::fetch(&rocket).unwrap().conn; + let _ = migration::Migrator::up(conn, None).await; + Ok(rocket) +} + +#[tokio::main] +async fn start() -> Result<(), rocket::Error> { + rocket::build() + .attach(Db::init()) + .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) + .mount("/", FileServer::from(relative!("/static"))) + .mount( + "/", + routes![new, create, delete, destroy, list, edit, update], + ) + .register("/", catchers![not_found]) + .attach(Template::fairing()) + .launch() + .await + .map(|_| ()) +} + +pub fn main() { + let result = start(); + + println!("Rocket: deorbit."); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/rocket_example/src/pool.rs b/examples/rocket_example/api/src/pool.rs similarity index 97% rename from examples/rocket_example/src/pool.rs rename to examples/rocket_example/api/src/pool.rs index fe5f8c6e..b1c05677 100644 --- a/examples/rocket_example/src/pool.rs +++ b/examples/rocket_example/api/src/pool.rs @@ -1,3 +1,5 @@ +use rocket_example_core::sea_orm; + use async_trait::async_trait; use sea_orm::ConnectOptions; use sea_orm_rocket::{rocket::figment::Figment, Config, Database}; diff --git a/examples/rocket_example/static/css/normalize.css b/examples/rocket_example/api/static/css/normalize.css similarity index 100% rename from examples/rocket_example/static/css/normalize.css rename to examples/rocket_example/api/static/css/normalize.css diff --git a/examples/rocket_example/static/css/skeleton.css b/examples/rocket_example/api/static/css/skeleton.css similarity index 100% rename from examples/rocket_example/static/css/skeleton.css rename to examples/rocket_example/api/static/css/skeleton.css diff --git a/examples/rocket_example/static/css/style.css b/examples/rocket_example/api/static/css/style.css similarity index 100% rename from examples/rocket_example/static/css/style.css rename to examples/rocket_example/api/static/css/style.css diff --git a/examples/rocket_example/static/images/favicon.png b/examples/rocket_example/api/static/images/favicon.png similarity index 100% rename from examples/rocket_example/static/images/favicon.png rename to examples/rocket_example/api/static/images/favicon.png diff --git a/examples/rocket_example/templates/base.html.tera b/examples/rocket_example/api/templates/base.html.tera similarity index 100% rename from examples/rocket_example/templates/base.html.tera rename to examples/rocket_example/api/templates/base.html.tera diff --git a/examples/rocket_example/templates/edit.html.tera b/examples/rocket_example/api/templates/edit.html.tera similarity index 100% rename from examples/rocket_example/templates/edit.html.tera rename to examples/rocket_example/api/templates/edit.html.tera diff --git a/examples/rocket_example/templates/error/404.html.tera b/examples/rocket_example/api/templates/error/404.html.tera similarity index 100% rename from examples/rocket_example/templates/error/404.html.tera rename to examples/rocket_example/api/templates/error/404.html.tera diff --git a/examples/rocket_example/templates/index.html.tera b/examples/rocket_example/api/templates/index.html.tera similarity index 100% rename from examples/rocket_example/templates/index.html.tera rename to examples/rocket_example/api/templates/index.html.tera diff --git a/examples/rocket_example/templates/new.html.tera b/examples/rocket_example/api/templates/new.html.tera similarity index 100% rename from examples/rocket_example/templates/new.html.tera rename to examples/rocket_example/api/templates/new.html.tera diff --git a/examples/rocket_example/core/Cargo.toml b/examples/rocket_example/core/Cargo.toml new file mode 100644 index 00000000..a57a5560 --- /dev/null +++ b/examples/rocket_example/core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rocket-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "runtime-tokio-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = "1.20.0" + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/rocket_example/core/src/lib.rs b/examples/rocket_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/rocket_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/rocket_example/core/src/mutation.rs b/examples/rocket_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/rocket_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/rocket_example/core/src/query.rs b/examples/rocket_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/rocket_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/rocket_example/core/tests/mock.rs b/examples/rocket_example/core/tests/mock.rs new file mode 100644 index 00000000..84b187e5 --- /dev/null +++ b/examples/rocket_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use rocket_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/rocket_example/core/tests/prepare.rs b/examples/rocket_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/rocket_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/rocket_example/src/main.rs b/examples/rocket_example/src/main.rs index 4187f6a1..182a6875 100644 --- a/examples/rocket_example/src/main.rs +++ b/examples/rocket_example/src/main.rs @@ -1,184 +1,3 @@ -#[macro_use] -extern crate rocket; - -use rocket::fairing::{self, AdHoc}; -use rocket::form::{Context, Form}; -use rocket::fs::{relative, FileServer}; -use rocket::request::FlashMessage; -use rocket::response::{Flash, Redirect}; -use rocket::{Build, Request, Rocket}; -use rocket_dyn_templates::Template; -use serde_json::json; - -use migration::MigratorTrait; -use sea_orm::{entity::*, query::*}; -use sea_orm_rocket::{Connection, Database}; - -mod pool; -use pool::Db; - -pub use entity::post; -pub use entity::post::Entity as Post; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; - -#[get("/new")] -async fn new() -> Template { - Template::render("new", &Context::default()) -} - -#[post("/", data = "")] -async fn create(conn: Connection<'_, Db>, post_form: Form) -> Flash { - let db = conn.into_inner(); - - let form = post_form.into_inner(); - - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(db) - .await - .expect("could not insert post"); - - Flash::success(Redirect::to("/"), "Post successfully added.") -} - -#[post("/", data = "")] -async fn update( - conn: Connection<'_, Db>, - id: i32, - post_form: Form, -) -> Flash { - let db = conn.into_inner(); - - let post: post::ActiveModel = Post::find_by_id(id).one(db).await.unwrap().unwrap().into(); - - let form = post_form.into_inner(); - - db.transaction::<_, (), sea_orm::DbErr>(|txn| { - Box::pin(async move { - post::ActiveModel { - id: post.id, - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(txn) - .await - .expect("could not edit post"); - - Ok(()) - }) - }) - .await - .unwrap(); - - Flash::success(Redirect::to("/"), "Post successfully edited.") -} - -#[get("/?&")] -async fn list( - conn: Connection<'_, Db>, - page: Option, - posts_per_page: Option, - flash: Option>, -) -> Template { - let db = conn.into_inner(); - - // Set page number and items per page - let page = page.unwrap_or(1); - let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); - if page == 0 { - panic!("Page number cannot be zero"); - } - - // Setup paginator - let paginator = Post::find() - .order_by_asc(post::Column::Id) - .paginate(db, posts_per_page); - let num_pages = paginator.num_pages().await.ok().unwrap(); - - // Fetch paginated posts - let posts = paginator - .fetch_page(page - 1) - .await - .expect("could not retrieve posts"); - - Template::render( - "index", - json! ({ - "page": page, - "posts_per_page": posts_per_page, - "num_pages": num_pages, - "posts": posts, - "flash": flash.map(FlashMessage::into_inner), - }), - ) -} - -#[get("/")] -async fn edit(conn: Connection<'_, Db>, id: i32) -> Template { - let db = conn.into_inner(); - - let post: Option = Post::find_by_id(id) - .one(db) - .await - .expect("could not find post"); - - Template::render( - "edit", - json! ({ - "post": post, - }), - ) -} - -#[delete("/")] -async fn delete(conn: Connection<'_, Db>, id: i32) -> Flash { - let db = conn.into_inner(); - - let post: post::ActiveModel = Post::find_by_id(id).one(db).await.unwrap().unwrap().into(); - - post.delete(db).await.unwrap(); - - Flash::success(Redirect::to("/"), "Post successfully deleted.") -} - -#[delete("/")] -async fn destroy(conn: Connection<'_, Db>) -> Result<(), rocket::response::Debug> { - let db = conn.into_inner(); - - Post::delete_many().exec(db).await.unwrap(); - Ok(()) -} - -#[catch(404)] -pub fn not_found(req: &Request<'_>) -> Template { - Template::render( - "error/404", - json! ({ - "uri": req.uri() - }), - ) -} - -async fn run_migrations(rocket: Rocket) -> fairing::Result { - let conn = &Db::fetch(&rocket).unwrap().conn; - let _ = migration::Migrator::up(conn, None).await; - Ok(rocket) -} - -#[launch] -fn rocket() -> _ { - rocket::build() - .attach(Db::init()) - .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) - .mount("/", FileServer::from(relative!("/static"))) - .mount( - "/", - routes![new, create, delete, destroy, list, edit, update], - ) - .register("/", catchers![not_found]) - .attach(Template::fairing()) +fn main() { + rocket_example_api::main(); } diff --git a/examples/salvo_example/Cargo.toml b/examples/salvo_example/Cargo.toml index a1028a77..aef02284 100644 --- a/examples/salvo_example/Cargo.toml +++ b/examples/salvo_example/Cargo.toml @@ -5,25 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] -tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } -salvo = { version = "0.27", features = ["affix", "serve-static"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -tera = "1.8.0" -dotenv = "0.15" -entity = { path = "entity" } -migration = { path = "migration" } - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-native-tls", - "sqlx-sqlite", - # "sqlx-postgres", - # "sqlx-mysql", -] +salvo-example-api = { path = "api" } diff --git a/examples/salvo_example/README.md b/examples/salvo_example/README.md index bd4a4539..d0aa6973 100644 --- a/examples/salvo_example/README.md +++ b/examples/salvo_example/README.md @@ -4,8 +4,15 @@ 1. Modify the `DATABASE_URL` var in `.env` to point to your chosen database -1. Turn on the appropriate database feature for your chosen db in `Cargo.toml` (the `"sqlx-sqlite",` line) +1. Turn on the appropriate database feature for your chosen db in `core/Cargo.toml` (the `"sqlx-sqlite",` line) 1. Execute `cargo run` to start the server 1. Visit [localhost:8000](http://localhost:8000) in browser after seeing the `server started` line + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/salvo_example/api/Cargo.toml b/examples/salvo_example/api/Cargo.toml new file mode 100644 index 00000000..cc26a774 --- /dev/null +++ b/examples/salvo_example/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "salvo-example-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +salvo-example-core = { path = "../core" } +tokio = { version = "1.15.0", features = ["macros", "rt-multi-thread"] } +salvo = { version = "0.27", features = ["affix", "serve-static"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +tera = "1.8.0" +dotenv = "0.15" +entity = { path = "../entity" } +migration = { path = "../migration" } diff --git a/examples/salvo_example/api/src/lib.rs b/examples/salvo_example/api/src/lib.rs new file mode 100644 index 00000000..22508055 --- /dev/null +++ b/examples/salvo_example/api/src/lib.rs @@ -0,0 +1,182 @@ +use std::env; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use salvo::extra::affix; +use salvo::extra::serve_static::DirHandler; +use salvo::prelude::*; +use salvo::writer::Text; +use salvo_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; +use tera::Tera; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; +type Result = std::result::Result; + +#[derive(Debug, Clone)] +struct AppState { + templates: tera::Tera, + conn: DatabaseConnection, +} + +#[handler] +async fn create(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + + let form = req + .extract_form::() + .await + .map_err(|_| StatusError::bad_request())?; + + Mutation::create_post(conn, form) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[handler] +async fn list(req: &mut Request, depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + + let page = req.query("page").unwrap_or(1); + let posts_per_page = req + .query("posts_per_page") + .unwrap_or(DEFAULT_POSTS_PER_PAGE); + + let (posts, num_pages) = Query::find_posts_in_page(conn, page, posts_per_page) + .await + .map_err(|_| StatusError::internal_server_error())?; + + let mut ctx = tera::Context::new(); + ctx.insert("posts", &posts); + ctx.insert("page", &page); + ctx.insert("posts_per_page", &posts_per_page); + ctx.insert("num_pages", &num_pages); + + let body = state + .templates + .render("index.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn new(depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let ctx = tera::Context::new(); + let body = state + .templates + .render("new.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn edit(req: &mut Request, depot: &mut Depot) -> Result> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + + let post: post::Model = Query::find_post_by_id(conn, id) + .await + .map_err(|_| StatusError::internal_server_error())? + .ok_or_else(StatusError::not_found)?; + + let mut ctx = tera::Context::new(); + ctx.insert("post", &post); + + let body = state + .templates + .render("edit.html.tera", &ctx) + .map_err(|_| StatusError::internal_server_error())?; + Ok(Text::Html(body)) +} + +#[handler] +async fn update(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + let form = req + .extract_form::() + .await + .map_err(|_| StatusError::bad_request())?; + + Mutation::update_post_by_id(conn, id, form) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[handler] +async fn delete(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { + let state = depot + .obtain::() + .ok_or_else(StatusError::internal_server_error)?; + let conn = &state.conn; + let id = req.param::("id").unwrap_or_default(); + + Mutation::delete_post(conn, id) + .await + .map_err(|_| StatusError::internal_server_error())?; + + res.redirect_found("/"); + Ok(()) +} + +#[tokio::main] +pub async fn main() { + std::env::set_var("RUST_LOG", "debug"); + tracing_subscriber::fmt::init(); + + // get env vars + dotenv::dotenv().ok(); + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); + let host = env::var("HOST").expect("HOST is not set in .env file"); + let port = env::var("PORT").expect("PORT is not set in .env file"); + let server_url = format!("{}:{}", host, port); + + // create post table if not exists + let conn = Database::connect(&db_url).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); + let state = AppState { templates, conn }; + + println!("Starting server at {}", server_url); + + let router = Router::new() + .hoop(affix::inject(state)) + .post(create) + .get(list) + .push(Router::with_path("new").get(new)) + .push(Router::with_path("").get(edit).post(update)) + .push(Router::with_path("delete/").post(delete)) + .push( + Router::with_path("static/<**>").get(DirHandler::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/static" + ))), + ); + + Server::new(TcpListener::bind(&format!("{}:{}", host, port))) + .serve(router) + .await; +} diff --git a/examples/salvo_example/static/css/normalize.css b/examples/salvo_example/api/static/css/normalize.css similarity index 100% rename from examples/salvo_example/static/css/normalize.css rename to examples/salvo_example/api/static/css/normalize.css diff --git a/examples/salvo_example/static/css/skeleton.css b/examples/salvo_example/api/static/css/skeleton.css similarity index 100% rename from examples/salvo_example/static/css/skeleton.css rename to examples/salvo_example/api/static/css/skeleton.css diff --git a/examples/salvo_example/static/css/style.css b/examples/salvo_example/api/static/css/style.css similarity index 100% rename from examples/salvo_example/static/css/style.css rename to examples/salvo_example/api/static/css/style.css diff --git a/examples/salvo_example/static/images/favicon.png b/examples/salvo_example/api/static/images/favicon.png similarity index 100% rename from examples/salvo_example/static/images/favicon.png rename to examples/salvo_example/api/static/images/favicon.png diff --git a/examples/salvo_example/templates/edit.html.tera b/examples/salvo_example/api/templates/edit.html.tera similarity index 100% rename from examples/salvo_example/templates/edit.html.tera rename to examples/salvo_example/api/templates/edit.html.tera diff --git a/examples/salvo_example/templates/error/404.html.tera b/examples/salvo_example/api/templates/error/404.html.tera similarity index 100% rename from examples/salvo_example/templates/error/404.html.tera rename to examples/salvo_example/api/templates/error/404.html.tera diff --git a/examples/salvo_example/templates/index.html.tera b/examples/salvo_example/api/templates/index.html.tera similarity index 100% rename from examples/salvo_example/templates/index.html.tera rename to examples/salvo_example/api/templates/index.html.tera diff --git a/examples/salvo_example/templates/layout.html.tera b/examples/salvo_example/api/templates/layout.html.tera similarity index 100% rename from examples/salvo_example/templates/layout.html.tera rename to examples/salvo_example/api/templates/layout.html.tera diff --git a/examples/salvo_example/templates/new.html.tera b/examples/salvo_example/api/templates/new.html.tera similarity index 100% rename from examples/salvo_example/templates/new.html.tera rename to examples/salvo_example/api/templates/new.html.tera diff --git a/examples/salvo_example/core/Cargo.toml b/examples/salvo_example/core/Cargo.toml new file mode 100644 index 00000000..58280ed2 --- /dev/null +++ b/examples/salvo_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "salvo-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-native-tls", + # "sqlx-mysql", + # "sqlx-postgres", + "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/salvo_example/core/src/lib.rs b/examples/salvo_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/salvo_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/salvo_example/core/src/mutation.rs b/examples/salvo_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/salvo_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/salvo_example/core/src/query.rs b/examples/salvo_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/salvo_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/salvo_example/core/tests/mock.rs b/examples/salvo_example/core/tests/mock.rs new file mode 100644 index 00000000..261652bf --- /dev/null +++ b/examples/salvo_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use salvo_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/salvo_example/core/tests/prepare.rs b/examples/salvo_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/salvo_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/salvo_example/src/main.rs b/examples/salvo_example/src/main.rs index ba38042b..687ea53e 100644 --- a/examples/salvo_example/src/main.rs +++ b/examples/salvo_example/src/main.rs @@ -1,191 +1,3 @@ -use std::env; - -use entity::post; -use migration::{Migrator, MigratorTrait}; -use salvo::extra::affix; -use salvo::extra::serve_static::DirHandler; -use salvo::prelude::*; -use salvo::writer::Text; -use sea_orm::{entity::*, query::*, DatabaseConnection}; -use tera::Tera; - -const DEFAULT_POSTS_PER_PAGE: u64 = 5; -type Result = std::result::Result; - -#[derive(Debug, Clone)] -struct AppState { - templates: tera::Tera, - conn: DatabaseConnection, -} - -#[handler] -async fn create(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let form = req - .extract_form::() - .await - .map_err(|_| StatusError::bad_request())?; - post::ActiveModel { - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - ..Default::default() - } - .save(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - - res.redirect_found("/"); - Ok(()) -} - -#[handler] -async fn list(req: &mut Request, depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let page = req.query("page").unwrap_or(1); - let posts_per_page = req - .query("posts_per_page") - .unwrap_or(DEFAULT_POSTS_PER_PAGE); - let paginator = post::Entity::find() - .order_by_asc(post::Column::Id) - .paginate(&state.conn, posts_per_page); - let num_pages = paginator - .num_pages() - .await - .map_err(|_| StatusError::bad_request())?; - let posts = paginator - .fetch_page(page - 1) - .await - .map_err(|_| StatusError::internal_server_error())?; - - let mut ctx = tera::Context::new(); - ctx.insert("posts", &posts); - ctx.insert("page", &page); - ctx.insert("posts_per_page", &posts_per_page); - ctx.insert("num_pages", &num_pages); - - let body = state - .templates - .render("index.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn new(depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let ctx = tera::Context::new(); - let body = state - .templates - .render("new.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn edit(req: &mut Request, depot: &mut Depot) -> Result> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let post: post::Model = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())? - .ok_or_else(StatusError::not_found)?; - - let mut ctx = tera::Context::new(); - ctx.insert("post", &post); - - let body = state - .templates - .render("edit.html.tera", &ctx) - .map_err(|_| StatusError::internal_server_error())?; - Ok(Text::Html(body)) -} - -#[handler] -async fn update(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let form = req - .extract_form::() - .await - .map_err(|_| StatusError::bad_request())?; - post::ActiveModel { - id: Set(id), - title: Set(form.title.to_owned()), - text: Set(form.text.to_owned()), - } - .save(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - res.redirect_found("/"); - Ok(()) -} - -#[handler] -async fn delete(req: &mut Request, depot: &mut Depot, res: &mut Response) -> Result<()> { - let state = depot - .obtain::() - .ok_or_else(StatusError::internal_server_error)?; - let id = req.param::("id").unwrap_or_default(); - let post: post::ActiveModel = post::Entity::find_by_id(id) - .one(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())? - .ok_or_else(StatusError::not_found)? - .into(); - post.delete(&state.conn) - .await - .map_err(|_| StatusError::internal_server_error())?; - - res.redirect_found("/"); - Ok(()) -} - -#[tokio::main] -async fn main() { - std::env::set_var("RUST_LOG", "debug"); - tracing_subscriber::fmt::init(); - - // get env vars - dotenv::dotenv().ok(); - let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); - let host = env::var("HOST").expect("HOST is not set in .env file"); - let port = env::var("PORT").expect("PORT is not set in .env file"); - let server_url = format!("{}:{}", host, port); - - // create post table if not exists - let conn = sea_orm::Database::connect(&db_url).await.unwrap(); - Migrator::up(&conn, None).await.unwrap(); - let templates = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap(); - let state = AppState { templates, conn }; - - println!("Starting server at {}", server_url); - - let router = Router::new() - .hoop(affix::inject(state)) - .post(create) - .get(list) - .push(Router::with_path("new").get(new)) - .push(Router::with_path("").get(edit).post(update)) - .push(Router::with_path("delete/").post(delete)) - .push( - Router::with_path("static/<**>").get(DirHandler::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/static" - ))), - ); - - Server::new(TcpListener::bind(&format!("{}:{}", host, port))) - .serve(router) - .await; +fn main() { + salvo_example_api::main(); } diff --git a/examples/tonic_example/Cargo.toml b/examples/tonic_example/Cargo.toml index 4bb3346f..d3d14704 100644 --- a/examples/tonic_example/Cargo.toml +++ b/examples/tonic_example/Cargo.toml @@ -7,37 +7,17 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [workspace] -members = [".", "entity", "migration"] +members = [".", "api", "core", "entity", "migration"] [dependencies] +tonic-example-api = { path = "api" } tonic = "0.7" tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "full"] } -entity = { path = "entity" } -migration = { path = "migration" } -prost = "0.10.0" -serde = "1.0" - -[dependencies.sea-orm] -path = "../../" # remove this line in your own project -version = "^0.10.0" # sea-orm version -features = [ - "debug-print", - "runtime-tokio-rustls", - # "sqlx-mysql", - "sqlx-postgres", - # "sqlx-sqlite", -] - -[lib] -path = "./src/lib.rs" [[bin]] -name="server" -path="./src/server.rs" +name = "server" +path = "./src/server.rs" [[bin]] -name="client" -path="./src/client.rs" - -[build-dependencies] -tonic-build = "0.7" +name = "client" +path = "./src/client.rs" diff --git a/examples/tonic_example/README.md b/examples/tonic_example/README.md index 22ef798f..7c11feac 100644 --- a/examples/tonic_example/README.md +++ b/examples/tonic_example/README.md @@ -3,11 +3,20 @@ Simple implementation of gRPC using SeaORM. run server using + ```bash cargo run --bin server ``` run client using + ```bash cargo run --bin client -``` \ No newline at end of file +``` + +Run mock test on the core logic crate: + +```bash +cd core +cargo test --features mock +``` diff --git a/examples/tonic_example/api/Cargo.toml b/examples/tonic_example/api/Cargo.toml new file mode 100644 index 00000000..6253c531 --- /dev/null +++ b/examples/tonic_example/api/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tonic-example-api" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +tonic-example-core = { path = "../core" } +tonic = "0.7" +tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "full"] } +entity = { path = "../entity" } +migration = { path = "../migration" } +prost = "0.10.0" +serde = "1.0" + +[lib] +path = "./src/lib.rs" + +[build-dependencies] +tonic-build = "0.7" diff --git a/examples/tonic_example/build.rs b/examples/tonic_example/api/build.rs similarity index 100% rename from examples/tonic_example/build.rs rename to examples/tonic_example/api/build.rs diff --git a/examples/tonic_example/proto/post.proto b/examples/tonic_example/api/proto/post.proto similarity index 100% rename from examples/tonic_example/proto/post.proto rename to examples/tonic_example/api/proto/post.proto diff --git a/examples/tonic_example/api/src/lib.rs b/examples/tonic_example/api/src/lib.rs new file mode 100644 index 00000000..9b3e2582 --- /dev/null +++ b/examples/tonic_example/api/src/lib.rs @@ -0,0 +1,143 @@ +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +use entity::post; +use migration::{Migrator, MigratorTrait}; +use tonic_example_core::{ + sea_orm::{Database, DatabaseConnection}, + Mutation, Query, +}; + +use std::env; + +pub mod post_mod { + tonic::include_proto!("post"); +} + +use post_mod::{ + blogpost_server::{Blogpost, BlogpostServer}, + Post, PostId, PostList, PostPerPage, ProcessStatus, +}; + +impl Post { + fn into_model(self) -> post::Model { + post::Model { + id: self.id, + title: self.title, + text: self.content, + } + } +} + +#[derive(Default)] +pub struct MyServer { + connection: DatabaseConnection, +} + +#[tonic::async_trait] +impl Blogpost for MyServer { + async fn get_posts(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let posts_per_page = request.into_inner().per_page; + + let mut response = PostList { post: Vec::new() }; + + let (posts, _) = Query::find_posts_in_page(conn, 1, posts_per_page) + .await + .expect("Cannot find posts in page"); + + for post in posts { + response.post.push(Post { + id: post.id, + title: post.title, + content: post.text, + }); + } + + Ok(Response::new(response)) + } + + async fn add_post(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + + let input = request.into_inner().into_model(); + + let inserted = Mutation::create_post(conn, input) + .await + .expect("could not insert post"); + + let response = PostId { + id: inserted.id.unwrap(), + }; + + Ok(Response::new(response)) + } + + async fn update_post(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let input = request.into_inner().into_model(); + + match Mutation::update_post_by_id(conn, input.id, input).await { + Ok(_) => Ok(Response::new(ProcessStatus { success: true })), + Err(_) => Ok(Response::new(ProcessStatus { success: false })), + } + } + + async fn delete_post( + &self, + request: Request, + ) -> Result, Status> { + let conn = &self.connection; + let id = request.into_inner().id; + + match Mutation::delete_post(conn, id).await { + Ok(_) => Ok(Response::new(ProcessStatus { success: true })), + Err(_) => Ok(Response::new(ProcessStatus { success: false })), + } + } + + async fn get_post_by_id(&self, request: Request) -> Result, Status> { + let conn = &self.connection; + let id = request.into_inner().id; + + if let Some(post) = Query::find_post_by_id(conn, id).await.ok().flatten() { + Ok(Response::new(Post { + id, + title: post.title, + content: post.text, + })) + } else { + Err(Status::new( + tonic::Code::Aborted, + "Could not find post with id ".to_owned() + &id.to_string(), + )) + } + } +} + +#[tokio::main] +async fn start() -> Result<(), Box> { + let addr = "0.0.0.0:50051".parse()?; + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + + // establish database connection + let connection = Database::connect(&database_url).await?; + Migrator::up(&connection, None).await?; + + let hello_server = MyServer { connection }; + Server::builder() + .add_service(BlogpostServer::new(hello_server)) + .serve(addr) + .await?; + + Ok(()) +} + +pub fn main() { + let result = start(); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/tonic_example/core/Cargo.toml b/examples/tonic_example/core/Cargo.toml new file mode 100644 index 00000000..dd9dd4e0 --- /dev/null +++ b/examples/tonic_example/core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tonic-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "debug-print", + "runtime-tokio-rustls", + # "sqlx-mysql", + "sqlx-postgres", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = { version = "1.20.0", features = ["macros", "rt"] } + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/tonic_example/core/src/lib.rs b/examples/tonic_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/tonic_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/tonic_example/core/src/mutation.rs b/examples/tonic_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/tonic_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/tonic_example/core/src/query.rs b/examples/tonic_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/tonic_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/tonic_example/core/tests/mock.rs b/examples/tonic_example/core/tests/mock.rs new file mode 100644 index 00000000..522d3e45 --- /dev/null +++ b/examples/tonic_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use tonic_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/tonic_example/core/tests/prepare.rs b/examples/tonic_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/tonic_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/tonic_example/src/client.rs b/examples/tonic_example/src/client.rs index 4cd46cb4..f63ad46a 100644 --- a/examples/tonic_example/src/client.rs +++ b/examples/tonic_example/src/client.rs @@ -1,7 +1,7 @@ use tonic::transport::Endpoint; use tonic::Request; -use sea_orm_tonic_example::post::{blogpost_client::BlogpostClient, PostPerPage}; +use tonic_example_api::post_mod::{blogpost_client::BlogpostClient, PostPerPage}; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/examples/tonic_example/src/lib.rs b/examples/tonic_example/src/lib.rs deleted file mode 100644 index cd202896..00000000 --- a/examples/tonic_example/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod post { - tonic::include_proto!("post"); -} diff --git a/examples/tonic_example/src/server.rs b/examples/tonic_example/src/server.rs index 0857ac7b..a9838cdc 100644 --- a/examples/tonic_example/src/server.rs +++ b/examples/tonic_example/src/server.rs @@ -1,130 +1,3 @@ -use tonic::transport::Server; -use tonic::{Request, Response, Status}; - -use sea_orm_tonic_example::post::{ - blogpost_server::{Blogpost, BlogpostServer}, - Post, PostId, PostList, PostPerPage, ProcessStatus, -}; - -use entity::post::{self, Entity as PostEntity}; -use migration::{Migrator, MigratorTrait}; -use sea_orm::{self, entity::*, query::*, DatabaseConnection}; - -use std::env; - -#[derive(Default)] -pub struct MyServer { - connection: DatabaseConnection, -} - -#[tonic::async_trait] -impl Blogpost for MyServer { - async fn get_posts(&self, request: Request) -> Result, Status> { - let mut response = PostList { post: Vec::new() }; - - let posts = PostEntity::find() - .order_by_asc(post::Column::Id) - .limit(request.into_inner().per_page) - .all(&self.connection) - .await - .unwrap(); - - for post in posts { - response.post.push(Post { - id: post.id, - title: post.title, - content: post.text, - }); - } - - Ok(Response::new(response)) - } - - async fn add_post(&self, request: Request) -> Result, Status> { - let input = request.into_inner(); - let insert_details = post::ActiveModel { - title: Set(input.title.clone()), - text: Set(input.content.clone()), - ..Default::default() - }; - - let response = PostId { - id: insert_details.insert(&self.connection).await.unwrap().id, - }; - - Ok(Response::new(response)) - } - - async fn update_post(&self, request: Request) -> Result, Status> { - let input = request.into_inner(); - let mut update_post: post::ActiveModel = PostEntity::find_by_id(input.id) - .one(&self.connection) - .await - .unwrap() - .unwrap() - .into(); - - update_post.title = Set(input.title.clone()); - update_post.text = Set(input.content.clone()); - - let update = update_post.update(&self.connection).await; - - match update { - Ok(_) => Ok(Response::new(ProcessStatus { success: true })), - Err(_) => Ok(Response::new(ProcessStatus { success: false })), - } - } - - async fn delete_post( - &self, - request: Request, - ) -> Result, Status> { - let delete_post: post::ActiveModel = PostEntity::find_by_id(request.into_inner().id) - .one(&self.connection) - .await - .unwrap() - .unwrap() - .into(); - - let status = delete_post.delete(&self.connection).await; - - match status { - Ok(_) => Ok(Response::new(ProcessStatus { success: true })), - Err(_) => Ok(Response::new(ProcessStatus { success: false })), - } - } - - async fn get_post_by_id(&self, request: Request) -> Result, Status> { - let post = PostEntity::find_by_id(request.into_inner().id) - .one(&self.connection) - .await - .unwrap() - .unwrap(); - - let response = Post { - id: post.id, - title: post.title, - content: post.text, - }; - Ok(Response::new(response)) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let addr = "0.0.0.0:50051".parse()?; - - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - - // establish database connection - let connection = sea_orm::Database::connect(&database_url).await?; - Migrator::up(&connection, None).await?; - - let hello_server = MyServer { connection }; - Server::builder() - .add_service(BlogpostServer::new(hello_server)) - .serve(addr) - .await?; - - Ok(()) +fn main() { + tonic_example_api::main(); } From 75e25708114e476a5b2c5b2c35c283075045ed53 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Fri, 23 Sep 2022 15:32:03 +0800 Subject: [PATCH 26/71] Fix Rust 1.64 clippy (#1064) --- src/executor/delete.rs | 2 +- src/executor/update.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/executor/delete.rs b/src/executor/delete.rs index 29ad02d3..08d1d8f1 100644 --- a/src/executor/delete.rs +++ b/src/executor/delete.rs @@ -52,7 +52,7 @@ impl Deleter { } /// Execute a DELETE operation - pub fn exec<'a, C>(self, db: &'a C) -> impl Future> + '_ + pub fn exec(self, db: &C) -> impl Future> + '_ where C: ConnectionTrait, { diff --git a/src/executor/update.rs b/src/executor/update.rs index a7b343a1..5531afa3 100644 --- a/src/executor/update.rs +++ b/src/executor/update.rs @@ -64,7 +64,7 @@ impl Updater { } /// Execute an update operation - pub fn exec<'a, C>(self, db: &'a C) -> impl Future> + '_ + pub fn exec(self, db: &C) -> impl Future> + '_ where C: ConnectionTrait, { From 9e57574de2aaf3dc0259a6ae5d30ed82fc68aa9a Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Fri, 23 Sep 2022 15:32:18 +0800 Subject: [PATCH 27/71] Run migration script defined in SeaQuery (#1063) --- .../m20220923_000001_seed_cake_table.rs | 39 +++++++++++++++++++ sea-orm-migration/tests/migrator/mod.rs | 2 + 2 files changed, 41 insertions(+) create mode 100644 sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs diff --git a/sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs b/sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs new file mode 100644 index 00000000..ece73e1a --- /dev/null +++ b/sea-orm-migration/tests/migrator/m20220923_000001_seed_cake_table.rs @@ -0,0 +1,39 @@ +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{entity::*, query::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let insert = Query::insert() + .into_table(Cake::Table) + .columns([Cake::Name]) + .values_panic(["Tiramisu".into()]) + .to_owned(); + + manager.exec_stmt(insert).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let delete = Query::delete() + .from_table(Cake::Table) + .and_where(Expr::col(Cake::Name).eq("Tiramisu")) + .to_owned(); + + manager.exec_stmt(delete).await?; + + Ok(()) + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +pub enum Cake { + Table, + Id, + Name, +} diff --git a/sea-orm-migration/tests/migrator/mod.rs b/sea-orm-migration/tests/migrator/mod.rs index fdb92d9e..d09e4cdd 100644 --- a/sea-orm-migration/tests/migrator/mod.rs +++ b/sea-orm-migration/tests/migrator/mod.rs @@ -3,6 +3,7 @@ use sea_orm_migration::prelude::*; mod m20220118_000001_create_cake_table; mod m20220118_000002_create_fruit_table; mod m20220118_000003_seed_cake_table; +mod m20220923_000001_seed_cake_table; pub struct Migrator; @@ -13,6 +14,7 @@ impl MigratorTrait for Migrator { Box::new(m20220118_000001_create_cake_table::Migration), Box::new(m20220118_000002_create_fruit_table::Migration), Box::new(m20220118_000003_seed_cake_table::Migration), + Box::new(m20220923_000001_seed_cake_table::Migration), ] } } From d77cf24e06bec5de603cb131de74014a4d283646 Mon Sep 17 00:00:00 2001 From: "Daniel Porteous (dport)" Date: Fri, 23 Sep 2022 00:36:10 -0700 Subject: [PATCH 28/71] Fix typos in ORM CLI help messages (#1060) --- sea-orm-cli/src/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sea-orm-cli/src/cli.rs b/sea-orm-cli/src/cli.rs index 107f0f0c..36414268 100644 --- a/sea-orm-cli/src/cli.rs +++ b/sea-orm-cli/src/cli.rs @@ -167,7 +167,7 @@ pub enum GenerateSubcommands { value_parser, long, default_value = "none", - help = "Automatically derive serde Serialize / Deserialize traits for the entity (none,\ + help = "Automatically derive serde Serialize / Deserialize traits for the entity (none, \ serialize, deserialize, both)" )] with_serde: String, @@ -177,7 +177,7 @@ pub enum GenerateSubcommands { long, default_value = "false", long_help = "Automatically derive the Copy trait on generated enums.\n\ - Enums generated from a database don't have associated data by default, and as such can\ + Enums generated from a database don't have associated data by default, and as such can \ derive Copy. " )] From 6ba8e1b9f1e0e088e3228b686db882d219c6bc72 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 25 Sep 2022 10:17:39 +0800 Subject: [PATCH 29/71] `DeriveRelation` on empty Relation enum (#1019) --- examples/axum_example/entity/src/post.rs | 8 +------- examples/graphql_example/entity/src/note.rs | 8 +------- issues/471/src/post.rs | 8 +------- issues/630/src/entity/underscores.rs | 8 +------- issues/630/src/entity/underscores_workaround.rs | 8 +------- sea-orm-codegen/src/entity/writer.rs | 10 +--------- sea-orm-codegen/tests/compact/filling.rs | 8 +------- .../tests/compact_with_schema_name/filling.rs | 8 +------- src/tests_cfg/lunch_set.rs | 8 +------- 9 files changed, 9 insertions(+), 65 deletions(-) diff --git a/examples/axum_example/entity/src/post.rs b/examples/axum_example/entity/src/post.rs index 76d78196..66a7b652 100644 --- a/examples/axum_example/entity/src/post.rs +++ b/examples/axum_example/entity/src/post.rs @@ -14,13 +14,7 @@ pub struct Model { pub text: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/graphql_example/entity/src/note.rs b/examples/graphql_example/entity/src/note.rs index 46f0c49b..a03f6d3f 100644 --- a/examples/graphql_example/entity/src/note.rs +++ b/examples/graphql_example/entity/src/note.rs @@ -13,15 +13,9 @@ pub struct Model { pub text: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} impl Entity { diff --git a/issues/471/src/post.rs b/issues/471/src/post.rs index 3bb4d6a3..1e2e6046 100644 --- a/issues/471/src/post.rs +++ b/issues/471/src/post.rs @@ -14,13 +14,7 @@ pub struct Model { pub text: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} diff --git a/issues/630/src/entity/underscores.rs b/issues/630/src/entity/underscores.rs index ddd0be85..4dd20ac1 100644 --- a/issues/630/src/entity/underscores.rs +++ b/issues/630/src/entity/underscores.rs @@ -15,15 +15,9 @@ pub struct Model { pub aa_b_c_d: i32, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} #[cfg(test)] diff --git a/issues/630/src/entity/underscores_workaround.rs b/issues/630/src/entity/underscores_workaround.rs index 6773ad15..68cc0108 100644 --- a/issues/630/src/entity/underscores_workaround.rs +++ b/issues/630/src/entity/underscores_workaround.rs @@ -20,15 +20,9 @@ pub struct Model { pub aa_b_c_d: i32, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} #[cfg(test)] diff --git a/sea-orm-codegen/src/entity/writer.rs b/sea-orm-codegen/src/entity/writer.rs index 19722e18..f9d6c754 100644 --- a/sea-orm-codegen/src/entity/writer.rs +++ b/sea-orm-codegen/src/entity/writer.rs @@ -282,16 +282,8 @@ impl EntityWriter { let mut code_blocks = vec![ imports, Self::gen_compact_model_struct(entity, with_serde, date_time_crate, schema_name), + Self::gen_compact_relation_enum(entity), ]; - let relation_defs = if entity.get_relation_enum_name().is_empty() { - vec![ - Self::gen_relation_enum(entity), - Self::gen_impl_relation_trait(entity), - ] - } else { - vec![Self::gen_compact_relation_enum(entity)] - }; - code_blocks.extend(relation_defs); code_blocks.extend(Self::gen_impl_related(entity)); code_blocks.extend(Self::gen_impl_conjunct_related(entity)); code_blocks.extend(vec![Self::gen_impl_active_model_behavior()]); diff --git a/sea-orm-codegen/tests/compact/filling.rs b/sea-orm-codegen/tests/compact/filling.rs index dfedb1a7..de92558e 100644 --- a/sea-orm-codegen/tests/compact/filling.rs +++ b/sea-orm-codegen/tests/compact/filling.rs @@ -10,15 +10,9 @@ pub struct Model { pub name: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl Related for Entity { fn to() -> RelationDef { super::cake_filling::Relation::Cake.def() diff --git a/sea-orm-codegen/tests/compact_with_schema_name/filling.rs b/sea-orm-codegen/tests/compact_with_schema_name/filling.rs index 94795811..ead70acd 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/filling.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/filling.rs @@ -10,15 +10,9 @@ pub struct Model { pub name: String, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl Related for Entity { fn to() -> RelationDef { super::cake_filling::Relation::Cake.def() diff --git a/src/tests_cfg/lunch_set.rs b/src/tests_cfg/lunch_set.rs index b063245e..c0665da5 100644 --- a/src/tests_cfg/lunch_set.rs +++ b/src/tests_cfg/lunch_set.rs @@ -11,13 +11,7 @@ pub struct Model { pub tea: Tea, } -#[derive(Copy, Clone, Debug, EnumIter)] +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} -impl RelationTrait for Relation { - fn def(&self) -> RelationDef { - panic!("No RelationDef") - } -} - impl ActiveModelBehavior for ActiveModel {} From 597f515c07d97a4074b00e5297063d2b3bd1a190 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 25 Sep 2022 10:20:40 +0800 Subject: [PATCH 30/71] CHANGELOG (#1019) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d92c95..25391648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 * Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 * Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 +* Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 ## 0.9.2 - 2022-08-20 From ba5a83d3a8e0d09ccefd75b9bfe187e4d7484cc8 Mon Sep 17 00:00:00 2001 From: kyoto7250 <50972773+kyoto7250@users.noreply.github.com> Date: Sun, 25 Sep 2022 11:21:44 +0900 Subject: [PATCH 31/71] distinct support in sea-orm (#902) * distinct support * remove feature flag * fix argument --- src/query/helper.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/query/helper.rs b/src/query/helper.rs index f476d60c..ed83d4e5 100644 --- a/src/query/helper.rs +++ b/src/query/helper.rs @@ -8,6 +8,8 @@ use sea_query::{ }; pub use sea_query::{Condition, ConditionalStatement, DynIden, JoinType, Order, OrderedStatement}; +use sea_query::IntoColumnRef; + // LINT: when the column does not appear in tables selected from // LINT: when there is a group by clause, but some columns don't have aggregate functions // LINT: when the join table or column does not exists @@ -172,6 +174,61 @@ pub trait QuerySelect: Sized { self } + /// Add a DISTINCT expression + /// ``` + /// use sea_orm::{entity::*, query::*, tests_cfg::cake, DbBackend}; + /// struct Input { + /// name: Option, + /// } + /// let input = Input { + /// name: Some("cheese".to_owned()), + /// }; + /// assert_eq!( + /// cake::Entity::find() + /// .filter( + /// Condition::all().add_option(input.name.map(|n| cake::Column::Name.contains(&n))) + /// ) + /// .distinct() + /// .build(DbBackend::MySql) + /// .to_string(), + /// "SELECT DISTINCT `cake`.`id`, `cake`.`name` FROM `cake` WHERE `cake`.`name` LIKE '%cheese%'" + /// ); + /// ``` + fn distinct(mut self) -> Self { + self.query().distinct(); + self + } + + /// Add a DISTINCT ON expression + /// NOTE: this function is only supported by `sqlx-postgres` + /// ``` + /// use sea_orm::{entity::*, query::*, tests_cfg::cake, DbBackend}; + /// struct Input { + /// name: Option, + /// } + /// let input = Input { + /// name: Some("cheese".to_owned()), + /// }; + /// assert_eq!( + /// cake::Entity::find() + /// .filter( + /// Condition::all().add_option(input.name.map(|n| cake::Column::Name.contains(&n))) + /// ) + /// .distinct_on([cake::Column::Name]) + /// .build(DbBackend::Postgres) + /// .to_string(), + /// "SELECT DISTINCT ON (\"name\") \"cake\".\"id\", \"cake\".\"name\" FROM \"cake\" WHERE \"cake\".\"name\" LIKE '%cheese%'" + /// ); + /// ``` + fn distinct_on(mut self, cols: I) -> Self + where + T: IntoColumnRef, + I: IntoIterator, + { + self.query().distinct_on(cols); + self + } + #[doc(hidden)] fn join_join(mut self, join: JoinType, rel: RelationDef, via: Option) -> Self { if let Some(via) = via { From fbdd3ea421e4b6ef054c33a22180b1566f096ce3 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 25 Sep 2022 10:23:16 +0800 Subject: [PATCH 32/71] CHANGELOG (#902) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25391648..79ac8a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 * Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 * Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 +* Support `distinct` & `distinct_on` expression https://github.com/SeaQL/sea-orm/pull/902 ## 0.9.2 - 2022-08-20 From 4acdaacebc0ceafd6b337a78bedd2b86e541fe3b Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 25 Sep 2022 10:24:27 +0800 Subject: [PATCH 33/71] Cont. "Delete all PostgreSQL types when calling fresh" (#991) * Delete all PostgreSQL types when calling fresh (#765) (#864) * Delete all PostgreSQL types when calling fresh (#765) * Test create db enum migration Co-authored-by: Billy Chan * Refactoring Co-authored-by: Denis Gavrilyuk --- sea-orm-migration/src/migrator.rs | 116 +++++++++++++++--- .../m20220118_000004_create_tea_enum.rs | 53 ++++++++ sea-orm-migration/tests/migrator/mod.rs | 2 + 3 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs diff --git a/sea-orm-migration/src/migrator.rs b/sea-orm-migration/src/migrator.rs index 5239d759..89a22475 100644 --- a/sea-orm-migration/src/migrator.rs +++ b/sea-orm-migration/src/migrator.rs @@ -3,7 +3,10 @@ use std::fmt::Display; use std::time::SystemTime; use tracing::info; -use sea_orm::sea_query::{Alias, Expr, ForeignKey, Query, SelectStatement, SimpleExpr, Table}; +use sea_orm::sea_query::{ + self, extension::postgres::Type, Alias, Expr, ForeignKey, Iden, JoinType, Query, + SelectStatement, SimpleExpr, Table, +}; use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, ConnectionTrait, DbBackend, DbConn, DbErr, EntityTrait, QueryFilter, QueryOrder, Schema, Statement, @@ -146,25 +149,7 @@ pub trait MigratorTrait: Send { // Drop all foreign keys if db_backend == DbBackend::MySql { info!("Dropping all foreign keys"); - let mut stmt = Query::select(); - stmt.columns([Alias::new("TABLE_NAME"), Alias::new("CONSTRAINT_NAME")]) - .from(( - Alias::new("information_schema"), - Alias::new("table_constraints"), - )) - .cond_where( - Condition::all() - .add( - Expr::expr(get_current_schema(db)).equals( - Alias::new("table_constraints"), - Alias::new("table_schema"), - ), - ) - .add(Expr::expr(Expr::value("FOREIGN KEY")).equals( - Alias::new("table_constraints"), - Alias::new("constraint_type"), - )), - ); + let stmt = query_mysql_foreign_keys(db); let rows = db.query_all(db_backend.build(&stmt)).await?; for row in rows.into_iter() { let constraint_name: String = row.try_get("", "CONSTRAINT_NAME")?; @@ -196,6 +181,21 @@ pub trait MigratorTrait: Send { info!("Table '{}' has been dropped", table_name); } + // Drop all types + if db_backend == DbBackend::Postgres { + info!("Dropping all types"); + let stmt = query_pg_types(db); + let rows = db.query_all(db_backend.build(&stmt)).await?; + for row in rows { + let type_name: String = row.try_get("", "typname")?; + info!("Dropping type '{}'", type_name); + let mut stmt = Type::drop(); + stmt.name(Alias::new(&type_name as &str)); + db.execute(db_backend.build(&stmt)).await?; + info!("Type '{}' has been dropped", type_name); + } + } + // Restore the foreign key check if db_backend == DbBackend::Sqlite { info!("Restoring foreign key check"); @@ -324,3 +324,79 @@ pub(crate) fn get_current_schema(db: &DbConn) -> SimpleExpr { DbBackend::Sqlite => unimplemented!(), } } + +#[derive(Iden)] +enum InformationSchema { + #[iden = "information_schema"] + Schema, + #[iden = "TABLE_NAME"] + TableName, + #[iden = "CONSTRAINT_NAME"] + ConstraintName, + TableConstraints, + TableSchema, + ConstraintType, +} + +fn query_mysql_foreign_keys(db: &DbConn) -> SelectStatement { + let mut stmt = Query::select(); + stmt.columns([ + InformationSchema::TableName, + InformationSchema::ConstraintName, + ]) + .from(( + InformationSchema::Schema, + InformationSchema::TableConstraints, + )) + .cond_where( + Condition::all() + .add(Expr::expr(get_current_schema(db)).equals( + InformationSchema::TableConstraints, + InformationSchema::TableSchema, + )) + .add( + Expr::tbl( + InformationSchema::TableConstraints, + InformationSchema::ConstraintType, + ) + .eq("FOREIGN KEY"), + ), + ); + stmt +} + +#[derive(Iden)] +enum PgType { + Table, + Typname, + Typnamespace, + Typelem, +} + +#[derive(Iden)] +enum PgNamespace { + Table, + Oid, + Nspname, +} + +fn query_pg_types(db: &DbConn) -> SelectStatement { + let mut stmt = Query::select(); + stmt.column(PgType::Typname) + .from(PgType::Table) + .join( + JoinType::LeftJoin, + PgNamespace::Table, + Expr::tbl(PgNamespace::Table, PgNamespace::Oid) + .equals(PgType::Table, PgType::Typnamespace), + ) + .cond_where( + Condition::all() + .add( + Expr::expr(get_current_schema(db)) + .equals(PgNamespace::Table, PgNamespace::Nspname), + ) + .add(Expr::tbl(PgType::Table, PgType::Typelem).eq(0)), + ); + stmt +} diff --git a/sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs b/sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs new file mode 100644 index 00000000..ba9b68e6 --- /dev/null +++ b/sea-orm-migration/tests/migrator/m20220118_000004_create_tea_enum.rs @@ -0,0 +1,53 @@ +use sea_orm_migration::prelude::{sea_query::extension::postgres::Type, *}; +use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + match db.get_database_backend() { + DbBackend::MySql | DbBackend::Sqlite => {} + DbBackend::Postgres => { + manager + .create_type( + Type::create() + .as_enum(Tea::Table) + .values([Tea::EverydayTea, Tea::BreakfastTea]) + .to_owned(), + ) + .await?; + } + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + match db.get_database_backend() { + DbBackend::MySql | DbBackend::Sqlite => {} + DbBackend::Postgres => { + manager + .drop_type(Type::drop().name(Tea::Table).to_owned()) + .await?; + } + } + + Ok(()) + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +pub enum Tea { + Table, + #[iden = "EverydayTea"] + EverydayTea, + #[iden = "BreakfastTea"] + BreakfastTea, +} diff --git a/sea-orm-migration/tests/migrator/mod.rs b/sea-orm-migration/tests/migrator/mod.rs index d09e4cdd..042e5323 100644 --- a/sea-orm-migration/tests/migrator/mod.rs +++ b/sea-orm-migration/tests/migrator/mod.rs @@ -3,6 +3,7 @@ use sea_orm_migration::prelude::*; mod m20220118_000001_create_cake_table; mod m20220118_000002_create_fruit_table; mod m20220118_000003_seed_cake_table; +mod m20220118_000004_create_tea_enum; mod m20220923_000001_seed_cake_table; pub struct Migrator; @@ -14,6 +15,7 @@ impl MigratorTrait for Migrator { Box::new(m20220118_000001_create_cake_table::Migration), Box::new(m20220118_000002_create_fruit_table::Migration), Box::new(m20220118_000003_seed_cake_table::Migration), + Box::new(m20220118_000004_create_tea_enum::Migration), Box::new(m20220923_000001_seed_cake_table::Migration), ] } From 6816e86f4d3af520d05a4de6f1dab36c51651294 Mon Sep 17 00:00:00 2001 From: Horu <73709188+HigherOrderLogic@users.noreply.github.com> Date: Sun, 25 Sep 2022 09:31:26 +0700 Subject: [PATCH 34/71] [cli] Add `-l`/`--lib` flag (#953) * [cli] Add `-l`/`--lib` flag * [cli] Change function name to reflect functionality --- sea-orm-cli/src/cli.rs | 9 +++++++++ sea-orm-cli/src/commands/generate.rs | 2 ++ sea-orm-codegen/src/entity/writer.rs | 15 ++++++++++++--- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/sea-orm-cli/src/cli.rs b/sea-orm-cli/src/cli.rs index 36414268..001a9b9b 100644 --- a/sea-orm-cli/src/cli.rs +++ b/sea-orm-cli/src/cli.rs @@ -191,6 +191,15 @@ pub enum GenerateSubcommands { help = "The datetime crate to use for generating entities." )] date_time_crate: DateTimeCrate, + + #[clap( + action, + long, + short = 'l', + default_value = "false", + help = "Generate index file as `lib.rs` instead of `mod.rs`." + )] + lib: bool, }, } diff --git a/sea-orm-cli/src/commands/generate.rs b/sea-orm-cli/src/commands/generate.rs index 0cea31e4..1686e878 100644 --- a/sea-orm-cli/src/commands/generate.rs +++ b/sea-orm-cli/src/commands/generate.rs @@ -26,6 +26,7 @@ pub async fn run_generate_command( with_serde, with_copy_enums, date_time_crate, + lib, } => { if verbose { let _ = tracing_subscriber::fmt() @@ -171,6 +172,7 @@ pub async fn run_generate_command( with_copy_enums, date_time_crate.into(), schema_name, + lib, ); let output = EntityTransformer::transform(table_stmts)?.generate(&writer_context); diff --git a/sea-orm-codegen/src/entity/writer.rs b/sea-orm-codegen/src/entity/writer.rs index f9d6c754..be6954ca 100644 --- a/sea-orm-codegen/src/entity/writer.rs +++ b/sea-orm-codegen/src/entity/writer.rs @@ -42,6 +42,7 @@ pub struct EntityWriterContext { pub(crate) with_copy_enums: bool, pub(crate) date_time_crate: DateTimeCrate, pub(crate) schema_name: Option, + pub(crate) lib: bool, } impl WithSerde { @@ -101,6 +102,7 @@ impl EntityWriterContext { with_copy_enums: bool, date_time_crate: DateTimeCrate, schema_name: Option, + lib: bool, ) -> Self { Self { expanded_format, @@ -108,6 +110,7 @@ impl EntityWriterContext { with_copy_enums, date_time_crate, schema_name, + lib, } } } @@ -116,7 +119,7 @@ impl EntityWriter { pub fn generate(self, context: &EntityWriterContext) -> WriterOutput { let mut files = Vec::new(); files.extend(self.write_entities(context)); - files.push(self.write_mod()); + files.push(self.write_index_file(context.lib)); files.push(self.write_prelude()); if !self.enums.is_empty() { files.push( @@ -168,7 +171,7 @@ impl EntityWriter { .collect() } - pub fn write_mod(&self) -> OutputFile { + pub fn write_index_file(&self, lib: bool) -> OutputFile { let mut lines = Vec::new(); Self::write_doc_comment(&mut lines); let code_blocks: Vec = self.entities.iter().map(Self::gen_mod).collect(); @@ -188,8 +191,14 @@ impl EntityWriter { }], ); } + + let file_name = match lib { + true => "lib.rs".to_owned(), + false => "mod.rs".to_owned(), + }; + OutputFile { - name: "mod.rs".to_owned(), + name: file_name, content: lines.join("\n"), } } From ad5e8c12643dbb0806adf60f190784d245a08e06 Mon Sep 17 00:00:00 2001 From: Animesh Sahu Date: Sun, 25 Sep 2022 08:03:50 +0530 Subject: [PATCH 35/71] [cli] Add -u, --universal-time option in `generate` to use Utc instead of Local (#947) --- sea-orm-cli/src/cli.rs | 8 ++++++++ sea-orm-cli/src/commands/migrate.rs | 19 +++++++++++++------ sea-orm-migration/src/cli.rs | 7 ++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/sea-orm-cli/src/cli.rs b/sea-orm-cli/src/cli.rs index 001a9b9b..827a17e1 100644 --- a/sea-orm-cli/src/cli.rs +++ b/sea-orm-cli/src/cli.rs @@ -52,6 +52,14 @@ pub enum MigrateSubcommands { help = "Name of the new migration" )] migration_name: String, + + #[clap( + action, + short, + long, + help = "Generate migration file based on Utc time instead of Local time" + )] + universal_time: bool, }, #[clap(about = "Drop all tables from the database, then reapply all migrations")] Fresh, diff --git a/sea-orm-cli/src/commands/migrate.rs b/sea-orm-cli/src/commands/migrate.rs index 2b9d7c4a..ac612a59 100644 --- a/sea-orm-cli/src/commands/migrate.rs +++ b/sea-orm-cli/src/commands/migrate.rs @@ -1,4 +1,4 @@ -use chrono::Local; +use chrono::{Local, Utc}; use regex::Regex; use std::{ error::Error, @@ -17,9 +17,10 @@ pub fn run_migrate_command( ) -> Result<(), Box> { match command { Some(MigrateSubcommands::Init) => run_migrate_init(migration_dir)?, - Some(MigrateSubcommands::Generate { migration_name }) => { - run_migrate_generate(migration_dir, &migration_name)? - } + Some(MigrateSubcommands::Generate { + migration_name, + universal_time, + }) => run_migrate_generate(migration_dir, &migration_name, universal_time)?, _ => { let (subcommand, migration_dir, steps, verbose) = match command { Some(MigrateSubcommands::Fresh) => ("fresh", migration_dir, None, verbose), @@ -110,12 +111,18 @@ pub fn run_migrate_init(migration_dir: &str) -> Result<(), Box> { pub fn run_migrate_generate( migration_dir: &str, migration_name: &str, + universal_time: bool, ) -> Result<(), Box> { println!("Generating new migration..."); // build new migration filename - let now = Local::now(); - let migration_name = format!("m{}_{}", now.format("%Y%m%d_%H%M%S"), migration_name); + const FMT: &str = "%Y%m%d_%H%M%S"; + let formatted_now = if universal_time { + Utc::now().format(FMT) + } else { + Local::now().format(FMT) + }; + let migration_name = format!("m{}_{}", formatted_now, migration_name); create_new_migration(&migration_name, migration_dir)?; update_migrator(&migration_name, migration_dir)?; diff --git a/sea-orm-migration/src/cli.rs b/sea-orm-migration/src/cli.rs index 09183f23..de228c40 100644 --- a/sea-orm-migration/src/cli.rs +++ b/sea-orm-migration/src/cli.rs @@ -65,9 +65,10 @@ where Some(MigrateSubcommands::Up { num }) => M::up(db, Some(num)).await?, Some(MigrateSubcommands::Down { num }) => M::down(db, Some(num)).await?, Some(MigrateSubcommands::Init) => run_migrate_init(MIGRATION_DIR)?, - Some(MigrateSubcommands::Generate { migration_name }) => { - run_migrate_generate(MIGRATION_DIR, &migration_name)? - } + Some(MigrateSubcommands::Generate { + migration_name, + universal_time, + }) => run_migrate_generate(MIGRATION_DIR, &migration_name, universal_time)?, _ => M::up(db, None).await?, }; From a349f13fd761291c723b855054a9e2dc81434aa5 Mon Sep 17 00:00:00 2001 From: "Lingxiang \"LinG\" Wang" Date: Sat, 24 Sep 2022 22:38:05 -0400 Subject: [PATCH 36/71] Struct / enum derive PartialEq should also derive Eq (#988) * add Eq * Fix clippy warnings * Fix test cases Co-authored-by: Billy Chan --- sea-orm-codegen/src/entity/active_enum.rs | 5 +- sea-orm-codegen/src/entity/base_entity.rs | 29 ++++- sea-orm-codegen/src/entity/writer.rs | 109 ++++++++++++++++-- sea-orm-codegen/tests/compact/cake.rs | 2 +- sea-orm-codegen/tests/compact/cake_filling.rs | 2 +- .../tests/compact/cake_with_double.rs | 37 ++++++ .../tests/compact/cake_with_float.rs | 37 ++++++ sea-orm-codegen/tests/compact/filling.rs | 2 +- sea-orm-codegen/tests/compact/fruit.rs | 2 +- sea-orm-codegen/tests/compact/rust_keyword.rs | 2 +- sea-orm-codegen/tests/compact/vendor.rs | 2 +- .../tests/compact_with_schema_name/cake.rs | 2 +- .../compact_with_schema_name/cake_filling.rs | 2 +- .../cake_with_double.rs | 37 ++++++ .../cake_with_float.rs | 37 ++++++ .../tests/compact_with_schema_name/filling.rs | 2 +- .../tests/compact_with_schema_name/fruit.rs | 2 +- .../compact_with_schema_name/rust_keyword.rs | 2 +- .../tests/compact_with_schema_name/vendor.rs | 2 +- .../tests/compact_with_serde/cake_both.rs | 2 +- .../compact_with_serde/cake_deserialize.rs | 2 +- .../tests/compact_with_serde/cake_none.rs | 2 +- .../compact_with_serde/cake_serialize.rs | 2 +- sea-orm-codegen/tests/expanded/cake.rs | 2 +- .../tests/expanded/cake_filling.rs | 2 +- .../tests/expanded/cake_with_double.rs | 80 +++++++++++++ .../tests/expanded/cake_with_float.rs | 80 +++++++++++++ sea-orm-codegen/tests/expanded/filling.rs | 2 +- sea-orm-codegen/tests/expanded/fruit.rs | 2 +- .../tests/expanded/rust_keyword.rs | 2 +- sea-orm-codegen/tests/expanded/vendor.rs | 2 +- .../tests/expanded_with_schema_name/cake.rs | 2 +- .../expanded_with_schema_name/cake_filling.rs | 2 +- .../cake_with_double.rs | 84 ++++++++++++++ .../cake_with_float.rs | 84 ++++++++++++++ .../expanded_with_schema_name/filling.rs | 2 +- .../tests/expanded_with_schema_name/fruit.rs | 2 +- .../expanded_with_schema_name/rust_keyword.rs | 2 +- .../tests/expanded_with_schema_name/vendor.rs | 2 +- .../tests/expanded_with_serde/cake_both.rs | 2 +- .../expanded_with_serde/cake_deserialize.rs | 2 +- .../tests/expanded_with_serde/cake_none.rs | 2 +- .../expanded_with_serde/cake_serialize.rs | 2 +- 43 files changed, 640 insertions(+), 43 deletions(-) create mode 100644 sea-orm-codegen/tests/compact/cake_with_double.rs create mode 100644 sea-orm-codegen/tests/compact/cake_with_float.rs create mode 100644 sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs create mode 100644 sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs create mode 100644 sea-orm-codegen/tests/expanded/cake_with_double.rs create mode 100644 sea-orm-codegen/tests/expanded/cake_with_float.rs create mode 100644 sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs create mode 100644 sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs diff --git a/sea-orm-codegen/src/entity/active_enum.rs b/sea-orm-codegen/src/entity/active_enum.rs index d4a7e600..ddcf2bdf 100644 --- a/sea-orm-codegen/src/entity/active_enum.rs +++ b/sea-orm-codegen/src/entity/active_enum.rs @@ -1,8 +1,9 @@ -use crate::WithSerde; use heck::CamelCase; use proc_macro2::TokenStream; use quote::{format_ident, quote}; +use crate::WithSerde; + #[derive(Clone, Debug)] pub struct ActiveEnum { pub(crate) enum_name: String, @@ -30,7 +31,7 @@ impl ActiveEnum { }; quote! { - #[derive(Debug, Clone, PartialEq, EnumIter, DeriveActiveEnum #copy_derive #extra_derive)] + #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum #copy_derive #extra_derive)] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = #enum_name)] pub enum #enum_iden { #( diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index a745b1f7..7e2ef8a9 100644 --- a/sea-orm-codegen/src/entity/base_entity.rs +++ b/sea-orm-codegen/src/entity/base_entity.rs @@ -1,7 +1,10 @@ -use crate::{Column, ConjunctRelation, DateTimeCrate, PrimaryKey, Relation}; use heck::{CamelCase, SnakeCase}; use proc_macro2::{Ident, TokenStream}; use quote::format_ident; +use quote::quote; +use sea_query::ColumnType; + +use crate::{Column, ConjunctRelation, DateTimeCrate, PrimaryKey, Relation}; #[derive(Clone, Debug)] pub struct Entity { @@ -145,14 +148,29 @@ impl Entity { .map(|con_rel| con_rel.get_to_camel_case()) .collect() } + + pub fn get_eq_needed(&self) -> TokenStream { + self.columns + .iter() + .find(|column| { + matches!( + column.col_type, + ColumnType::Float(_) | ColumnType::Double(_) + ) + }) + // check if float or double exist. + // if exist, return nothing + .map_or(quote! {, Eq}, |_| quote! {}) + } } #[cfg(test)] mod tests { - use crate::{Column, DateTimeCrate, Entity, PrimaryKey, Relation, RelationType}; use quote::format_ident; use sea_query::{ColumnType, ForeignKeyAction}; + use crate::{Column, DateTimeCrate, Entity, PrimaryKey, Relation, RelationType}; + fn setup() -> Entity { Entity { table_name: "special_cake".to_owned(), @@ -416,4 +434,11 @@ mod tests { assert_eq!(elem, entity.conjunct_relations[i].get_to_camel_case()); } } + + #[test] + fn test_get_eq_needed() { + let entity = setup(); + + println!("entity: {:?}", entity.get_eq_needed()); + } } diff --git a/sea-orm-codegen/src/entity/writer.rs b/sea-orm-codegen/src/entity/writer.rs index be6954ca..c0a74e73 100644 --- a/sea-orm-codegen/src/entity/writer.rs +++ b/sea-orm-codegen/src/entity/writer.rs @@ -381,11 +381,11 @@ impl EntityWriter { ) -> TokenStream { let column_names_snake_case = entity.get_column_names_snake_case(); let column_rs_types = entity.get_column_rs_types(date_time_crate); - + let if_eq_needed = entity.get_eq_needed(); let extra_derive = with_serde.extra_derive(); quote! { - #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel #extra_derive)] + #[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel #if_eq_needed #extra_derive)] pub struct Model { #(pub #column_names_snake_case: #column_rs_types,)* } @@ -567,6 +567,7 @@ impl EntityWriter { let table_name = entity.table_name.as_str(); let column_names_snake_case = entity.get_column_names_snake_case(); let column_rs_types = entity.get_column_rs_types(date_time_crate); + let if_eq_needed = entity.get_eq_needed(); let primary_keys: Vec = entity .primary_keys .iter() @@ -621,7 +622,7 @@ impl EntityWriter { let extra_derive = with_serde.extra_derive(); quote! { - #[derive(Clone, Debug, PartialEq, DeriveEntityModel #extra_derive)] + #[derive(Clone, Debug, PartialEq, DeriveEntityModel #if_eq_needed #extra_derive)] #[sea_orm( #schema_name table_name = #table_name @@ -1033,6 +1034,92 @@ mod tests { name: "id".to_owned(), }], }, + Entity { + table_name: "cake_with_float".to_owned(), + columns: vec![ + Column { + name: "id".to_owned(), + col_type: ColumnType::Integer(Some(11)), + auto_increment: true, + not_null: true, + unique: false, + }, + Column { + name: "name".to_owned(), + col_type: ColumnType::Text, + auto_increment: false, + not_null: false, + unique: false, + }, + Column { + name: "price".to_owned(), + col_type: ColumnType::Float(Some(2)), + auto_increment: false, + not_null: false, + unique: false, + }, + ], + relations: vec![Relation { + ref_table: "fruit".to_owned(), + columns: vec![], + ref_columns: vec![], + rel_type: RelationType::HasMany, + on_delete: None, + on_update: None, + self_referencing: false, + num_suffix: 0, + }], + conjunct_relations: vec![ConjunctRelation { + via: "cake_filling".to_owned(), + to: "filling".to_owned(), + }], + primary_keys: vec![PrimaryKey { + name: "id".to_owned(), + }], + }, + Entity { + table_name: "cake_with_double".to_owned(), + columns: vec![ + Column { + name: "id".to_owned(), + col_type: ColumnType::Integer(Some(11)), + auto_increment: true, + not_null: true, + unique: false, + }, + Column { + name: "name".to_owned(), + col_type: ColumnType::Text, + auto_increment: false, + not_null: false, + unique: false, + }, + Column { + name: "price".to_owned(), + col_type: ColumnType::Double(Some(2)), + auto_increment: false, + not_null: false, + unique: false, + }, + ], + relations: vec![Relation { + ref_table: "fruit".to_owned(), + columns: vec![], + ref_columns: vec![], + rel_type: RelationType::HasMany, + on_delete: None, + on_update: None, + self_referencing: false, + num_suffix: 0, + }], + conjunct_relations: vec![ConjunctRelation { + via: "cake_filling".to_owned(), + to: "filling".to_owned(), + }], + primary_keys: vec![PrimaryKey { + name: "id".to_owned(), + }], + }, ] } @@ -1057,21 +1144,25 @@ mod tests { #[test] fn test_gen_expanded_code_blocks() -> io::Result<()> { let entities = setup(); - const ENTITY_FILES: [&str; 6] = [ + const ENTITY_FILES: [&str; 8] = [ include_str!("../../tests/expanded/cake.rs"), include_str!("../../tests/expanded/cake_filling.rs"), include_str!("../../tests/expanded/filling.rs"), include_str!("../../tests/expanded/fruit.rs"), include_str!("../../tests/expanded/vendor.rs"), include_str!("../../tests/expanded/rust_keyword.rs"), + include_str!("../../tests/expanded/cake_with_float.rs"), + include_str!("../../tests/expanded/cake_with_double.rs"), ]; - const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 6] = [ + const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 8] = [ include_str!("../../tests/expanded_with_schema_name/cake.rs"), include_str!("../../tests/expanded_with_schema_name/cake_filling.rs"), include_str!("../../tests/expanded_with_schema_name/filling.rs"), include_str!("../../tests/expanded_with_schema_name/fruit.rs"), include_str!("../../tests/expanded_with_schema_name/vendor.rs"), include_str!("../../tests/expanded_with_schema_name/rust_keyword.rs"), + include_str!("../../tests/expanded_with_schema_name/cake_with_float.rs"), + include_str!("../../tests/expanded_with_schema_name/cake_with_double.rs"), ]; assert_eq!(entities.len(), ENTITY_FILES.len()); @@ -1133,21 +1224,25 @@ mod tests { #[test] fn test_gen_compact_code_blocks() -> io::Result<()> { let entities = setup(); - const ENTITY_FILES: [&str; 6] = [ + const ENTITY_FILES: [&str; 8] = [ include_str!("../../tests/compact/cake.rs"), include_str!("../../tests/compact/cake_filling.rs"), include_str!("../../tests/compact/filling.rs"), include_str!("../../tests/compact/fruit.rs"), include_str!("../../tests/compact/vendor.rs"), include_str!("../../tests/compact/rust_keyword.rs"), + include_str!("../../tests/compact/cake_with_float.rs"), + include_str!("../../tests/compact/cake_with_double.rs"), ]; - const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 6] = [ + const ENTITY_FILES_WITH_SCHEMA_NAME: [&str; 8] = [ include_str!("../../tests/compact_with_schema_name/cake.rs"), include_str!("../../tests/compact_with_schema_name/cake_filling.rs"), include_str!("../../tests/compact_with_schema_name/filling.rs"), include_str!("../../tests/compact_with_schema_name/fruit.rs"), include_str!("../../tests/compact_with_schema_name/vendor.rs"), include_str!("../../tests/compact_with_schema_name/rust_keyword.rs"), + include_str!("../../tests/compact_with_schema_name/cake_with_float.rs"), + include_str!("../../tests/compact_with_schema_name/cake_with_double.rs"), ]; assert_eq!(entities.len(), ENTITY_FILES.len()); diff --git a/sea-orm-codegen/tests/compact/cake.rs b/sea-orm-codegen/tests/compact/cake.rs index 2e26257c..7451140d 100644 --- a/sea-orm-codegen/tests/compact/cake.rs +++ b/sea-orm-codegen/tests/compact/cake.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/cake_filling.rs b/sea-orm-codegen/tests/compact/cake_filling.rs index ba9ed2ca..de27153d 100644 --- a/sea-orm-codegen/tests/compact/cake_filling.rs +++ b/sea-orm-codegen/tests/compact/cake_filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "_cake_filling_")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/sea-orm-codegen/tests/compact/cake_with_double.rs b/sea-orm-codegen/tests/compact/cake_with_double.rs new file mode 100644 index 00000000..edf4a991 --- /dev/null +++ b/sea-orm-codegen/tests/compact/cake_with_double.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "cake_with_double")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Double(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact/cake_with_float.rs b/sea-orm-codegen/tests/compact/cake_with_float.rs new file mode 100644 index 00000000..a5f9f701 --- /dev/null +++ b/sea-orm-codegen/tests/compact/cake_with_float.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "cake_with_float")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Float(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact/filling.rs b/sea-orm-codegen/tests/compact/filling.rs index de92558e..58f5f05c 100644 --- a/sea-orm-codegen/tests/compact/filling.rs +++ b/sea-orm-codegen/tests/compact/filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "filling")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/fruit.rs b/sea-orm-codegen/tests/compact/fruit.rs index 6399a51f..6ead03d1 100644 --- a/sea-orm-codegen/tests/compact/fruit.rs +++ b/sea-orm-codegen/tests/compact/fruit.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "fruit")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/rust_keyword.rs b/sea-orm-codegen/tests/compact/rust_keyword.rs index c5d46f50..1daeba8e 100644 --- a/sea-orm-codegen/tests/compact/rust_keyword.rs +++ b/sea-orm-codegen/tests/compact/rust_keyword.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "rust_keyword")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact/vendor.rs b/sea-orm-codegen/tests/compact/vendor.rs index 1351c227..f14c2808 100644 --- a/sea-orm-codegen/tests/compact/vendor.rs +++ b/sea-orm-codegen/tests/compact/vendor.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "vendor")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake.rs index d2efb986..b8418d64 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/cake.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs index a9704a8a..7ca8eb91 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake_filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "_cake_filling_")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs new file mode 100644 index 00000000..4afb7d6a --- /dev/null +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_double.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(schema_name = "schema_name", table_name = "cake_with_double")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Double(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs new file mode 100644 index 00000000..cf84a0a3 --- /dev/null +++ b/sea-orm-codegen/tests/compact_with_schema_name/cake_with_float.rs @@ -0,0 +1,37 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(schema_name = "schema_name", table_name = "cake_with_float")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "Text", nullable)] + pub name: Option , + #[sea_orm(column_type = "Float(Some(2))", nullable)] + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::fruit::Entity")] + Fruit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/compact_with_schema_name/filling.rs b/sea-orm-codegen/tests/compact_with_schema_name/filling.rs index ead70acd..f68ca06c 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/filling.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/filling.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "filling")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs b/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs index 28104324..dc446b1d 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/fruit.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "fruit")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs b/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs index f3ca0314..014836ea 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/rust_keyword.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "rust_keyword")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs b/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs index 85209c45..c3909880 100644 --- a/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs +++ b/sea-orm-codegen/tests/compact_with_schema_name/vendor.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(schema_name = "schema_name", table_name = "vendor")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_both.rs b/sea-orm-codegen/tests/compact_with_serde/cake_both.rs index 3a1bea9a..54d3cd16 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_both.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_both.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude:: * ; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs b/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs index b36718f9..f11569e4 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_deserialize.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude:: * ; use serde::Deserialize; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Deserialize)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_none.rs b/sea-orm-codegen/tests/compact_with_serde/cake_none.rs index 809b9051..d72ea6b2 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_none.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_none.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude:: * ; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs b/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs index 81cc3907..77af4c5a 100644 --- a/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs +++ b/sea-orm-codegen/tests/compact_with_serde/cake_serialize.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude:: * ; use serde::Serialize; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize)] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize)] #[sea_orm(table_name = "cake")] pub struct Model { #[sea_orm(primary_key)] diff --git a/sea-orm-codegen/tests/expanded/cake.rs b/sea-orm-codegen/tests/expanded/cake.rs index 0b33b618..961b1919 100644 --- a/sea-orm-codegen/tests/expanded/cake.rs +++ b/sea-orm-codegen/tests/expanded/cake.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded/cake_filling.rs b/sea-orm-codegen/tests/expanded/cake_filling.rs index d100fa8c..92e157f2 100644 --- a/sea-orm-codegen/tests/expanded/cake_filling.rs +++ b/sea-orm-codegen/tests/expanded/cake_filling.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub cake_id: i32, pub filling_id: i32, diff --git a/sea-orm-codegen/tests/expanded/cake_with_double.rs b/sea-orm-codegen/tests/expanded/cake_with_double.rs new file mode 100644 index 00000000..d71a9fbd --- /dev/null +++ b/sea-orm-codegen/tests/expanded/cake_with_double.rs @@ -0,0 +1,80 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "cake_with_double" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Double.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded/cake_with_float.rs b/sea-orm-codegen/tests/expanded/cake_with_float.rs new file mode 100644 index 00000000..d5059be1 --- /dev/null +++ b/sea-orm-codegen/tests/expanded/cake_with_float.rs @@ -0,0 +1,80 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "cake_with_float" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Float.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded/filling.rs b/sea-orm-codegen/tests/expanded/filling.rs index 76764ecf..22035244 100644 --- a/sea-orm-codegen/tests/expanded/filling.rs +++ b/sea-orm-codegen/tests/expanded/filling.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded/fruit.rs b/sea-orm-codegen/tests/expanded/fruit.rs index 12919021..df1618ca 100644 --- a/sea-orm-codegen/tests/expanded/fruit.rs +++ b/sea-orm-codegen/tests/expanded/fruit.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded/rust_keyword.rs b/sea-orm-codegen/tests/expanded/rust_keyword.rs index 1e96f791..8cdec4d8 100644 --- a/sea-orm-codegen/tests/expanded/rust_keyword.rs +++ b/sea-orm-codegen/tests/expanded/rust_keyword.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub testing: i8, diff --git a/sea-orm-codegen/tests/expanded/vendor.rs b/sea-orm-codegen/tests/expanded/vendor.rs index ab4ae39a..91cbbd47 100644 --- a/sea-orm-codegen/tests/expanded/vendor.rs +++ b/sea-orm-codegen/tests/expanded/vendor.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs index d37c2b08..72bdb0b1 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs index cde112e7..0113751f 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake_filling.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub cake_id: i32, pub filling_id: i32, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs new file mode 100644 index 00000000..0956e3e5 --- /dev/null +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_double.rs @@ -0,0 +1,84 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn schema_name(&self) -> Option< &str > { + Some("schema_name") + } + + fn table_name(&self) -> &str { + "cake_with_double" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Double.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithDouble.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs new file mode 100644 index 00000000..b3256ca2 --- /dev/null +++ b/sea-orm-codegen/tests/expanded_with_schema_name/cake_with_float.rs @@ -0,0 +1,84 @@ +//! SeaORM Entity. Generated by sea-orm-codegen 0.1.0 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn schema_name(&self) -> Option< &str > { + Some("schema_name") + } + + fn table_name(&self) -> &str { + "cake_with_float" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +pub struct Model { + pub id: i32, + pub name: Option , + pub price: Option , +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Id, + Name, + Price, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Id, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + Fruit, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Id => ColumnType::Integer.def(), + Self::Name => ColumnType::Text.def().null(), + Self::Price => ColumnType::Float.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::Fruit => Entity::has_many(super::fruit::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Fruit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::cake_filling::Relation::Filling.def() + } + fn via() -> Option { + Some(super::cake_filling::Relation::CakeWithFloat.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs b/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs index eb9005fb..918fd1bf 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/filling.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs b/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs index 2b554f6e..e2268ba6 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/fruit.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs b/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs index faa8310f..b402bdac 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/rust_keyword.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub testing: i8, diff --git a/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs b/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs index fd3be27e..1ad920ab 100644 --- a/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs +++ b/sea-orm-codegen/tests/expanded_with_schema_name/vendor.rs @@ -15,7 +15,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: String, diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs index 88821135..924887b4 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_both.rs @@ -12,7 +12,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq, Serialize, Deserialize)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs index 068f17da..88a7c3a9 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_deserialize.rs @@ -12,7 +12,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Deserialize)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq, Deserialize)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs index d0a1299a..a540fad1 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_none.rs @@ -11,7 +11,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] pub struct Model { pub id: i32, pub name: Option , diff --git a/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs b/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs index 30313fa1..72a17d58 100644 --- a/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs +++ b/sea-orm-codegen/tests/expanded_with_serde/cake_serialize.rs @@ -12,7 +12,7 @@ impl EntityName for Entity { } } -#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Serialize)] +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq, Serialize)] pub struct Model { pub id: i32, pub name: Option , From e29a11eb4557a443228fe6f8a011e6f696c4d893 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 25 Sep 2022 10:39:41 +0800 Subject: [PATCH 37/71] CHANGELOG (#902, #953, #947, #864, #991, #988) --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79ac8a74..12aee697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## 0.10.0 - Pending +### New Features +* Support `distinct` & `distinct_on` expression https://github.com/SeaQL/sea-orm/pull/902 +* Generate entity files as a library or module https://github.com/SeaQL/sea-orm/pull/953 +* Generate a new migration template with name prefix of unix timestamp https://github.com/SeaQL/sea-orm/pull/947 + ### Breaking changes * Replaced `usize` with `u64` in `PaginatorTrait` https://github.com/SeaQL/sea-orm/pull/789 @@ -17,7 +22,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 * Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 * Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 -* Support `distinct` & `distinct_on` expression https://github.com/SeaQL/sea-orm/pull/902 +* `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 +* Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 + ## 0.9.2 - 2022-08-20 From f3b7febc2e01d343b4b52f8a26eab2fe44313293 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 25 Sep 2022 10:43:50 +0800 Subject: [PATCH 38/71] Edit test case --- sea-orm-codegen/src/entity/base_entity.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sea-orm-codegen/src/entity/base_entity.rs b/sea-orm-codegen/src/entity/base_entity.rs index 7e2ef8a9..0147c6f0 100644 --- a/sea-orm-codegen/src/entity/base_entity.rs +++ b/sea-orm-codegen/src/entity/base_entity.rs @@ -166,7 +166,7 @@ impl Entity { #[cfg(test)] mod tests { - use quote::format_ident; + use quote::{format_ident, quote}; use sea_query::{ColumnType, ForeignKeyAction}; use crate::{Column, DateTimeCrate, Entity, PrimaryKey, Relation, RelationType}; @@ -438,7 +438,8 @@ mod tests { #[test] fn test_get_eq_needed() { let entity = setup(); + let expected = quote! {, Eq}; - println!("entity: {:?}", entity.get_eq_needed()); + assert_eq!(entity.get_eq_needed().to_string(), expected.to_string()); } } From 207e2df48470c2506272d83f2f91a884a553350c Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Mon, 26 Sep 2022 00:27:39 +0800 Subject: [PATCH 39/71] Should run this and cancel previous CI --- .github/workflows/rust.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 037d21de..49d801ec 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -55,10 +55,11 @@ on: push: branches: - master - - 0.2.x + - 0.*.x + - pr/**/ci concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }} cancel-in-progress: true env: From 55f16ec7f3eea6818f9ebcfb215be8bcff2cb35c Mon Sep 17 00:00:00 2001 From: Tushar Dahiya Date: Tue, 27 Sep 2022 21:43:35 +0530 Subject: [PATCH 40/71] Chore: docs grammar fix (#1050) * Update DESIGN.md * Update CONTRIBUTING.md * Update COMMUNITY.md * Update COMMUNITY.md * Update CHANGELOG.md --- CHANGELOG.md | 28 ++++++++++++++-------------- COMMUNITY.md | 8 ++++---- CONTRIBUTING.md | 8 ++++---- DESIGN.md | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12aee697..8eccdba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * `SelectTwoMany::one()` has been dropped https://github.com/SeaQL/sea-orm/pull/813, you can get `(Entity, Vec)` by first querying a single model from Entity, then use [`ModelTrait::find_related`] on the model. * #### Feature flag revamp - We now adopt the [weak dependency](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html#new-syntax-for-cargo-features) syntax in Cargo. That means the flags `["sqlx-json", "sqlx-chrono", "sqlx-decimal", "sqlx-uuid", "sqlx-time"]` are not needed and now removed. Instead, `with-time` will enable `sqlx?/time` only if `sqlx` is already enabled. As a consequence, now the features `with-json`, `with-chrono`, `with-rust_decimal`, `with-uuid`, `with-time` will not be enabled as a side-effects of enabling `sqlx`. + We now adopt the [weak dependency](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html#new-syntax-for-cargo-features) syntax in Cargo. That means the flags `["sqlx-json", "sqlx-chrono", "sqlx-decimal", "sqlx-uuid", "sqlx-time"]` are not needed and now removed. Instead, `with-time` will enable `sqlx?/time` only if `sqlx` is already enabled. As a consequence, now the features `with-json`, `with-chrono`, `with-rust_decimal`, `with-uuid`, `with-time` will not be enabled as a side-effect of enabling `sqlx`. ## sea-orm-migration 0.8.3 @@ -197,11 +197,11 @@ In this minor release, we removed `time` v0.1 from the dependency graph ### Fixed Issues * orm-cli generated incorrect type for #[sea_orm(primary_key)]. Should be u64. Was i64. https://github.com/SeaQL/sea-orm/issues/295 -* how to update dynamicly from json value https://github.com/SeaQL/sea-orm/issues/346 +* how to update dynamically from json value https://github.com/SeaQL/sea-orm/issues/346 * Make `DatabaseConnection` `Clone` with the default features enabled https://github.com/SeaQL/sea-orm/issues/438 -* Updating mutiple fields in a Model by passing a reference https://github.com/SeaQL/sea-orm/issues/460 +* Updating multiple fields in a Model by passing a reference https://github.com/SeaQL/sea-orm/issues/460 * SeaORM CLI not adding serde derives to Enums https://github.com/SeaQL/sea-orm/issues/461 -* sea-orm-cli generates wrong datatype for nullable blob https://github.com/SeaQL/sea-orm/issues/490 +* sea-orm-cli generates wrong data type for nullable blob https://github.com/SeaQL/sea-orm/issues/490 * Support the time crate in addition (instead of?) chrono https://github.com/SeaQL/sea-orm/issues/499 * PaginatorTrait for SelectorRaw https://github.com/SeaQL/sea-orm/issues/500 * sea_orm::DatabaseConnection should implement `Clone` by default https://github.com/SeaQL/sea-orm/issues/517 @@ -209,7 +209,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Datetime fields are not serialized by `.into_json()` on queries https://github.com/SeaQL/sea-orm/issues/530 * Update / Delete by id https://github.com/SeaQL/sea-orm/issues/552 * `#[sea_orm(indexed)]` only works for MySQL https://github.com/SeaQL/sea-orm/issues/554 -* `sea-orm-cli generate --with-serde` does not work on Postegresql custom type https://github.com/SeaQL/sea-orm/issues/581 +* `sea-orm-cli generate --with-serde` does not work on Postgresql custom type https://github.com/SeaQL/sea-orm/issues/581 * `sea-orm-cli generate --expanded-format` panic when postgres table contains enum type https://github.com/SeaQL/sea-orm/issues/614 * UUID fields are not serialized by `.into_json()` on queries https://github.com/SeaQL/sea-orm/issues/619 @@ -252,7 +252,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph ## 0.5.0 - 2022-01-01 ### Fixed Issues -* Why insert, update, etc return a ActiveModel instead of Model? https://github.com/SeaQL/sea-orm/issues/289 +* Why insert, update, etc return an ActiveModel instead of Model? https://github.com/SeaQL/sea-orm/issues/289 * Rework `ActiveValue` https://github.com/SeaQL/sea-orm/issues/321 * Some missing ActiveEnum utilities https://github.com/SeaQL/sea-orm/issues/338 @@ -268,7 +268,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Add docker create script for contributors to setup databases locally by @billy1624 in https://github.com/SeaQL/sea-orm/pull/378 * Log with tracing-subscriber by @billy1624 in https://github.com/SeaQL/sea-orm/pull/399 * Codegen SQLite by @billy1624 in https://github.com/SeaQL/sea-orm/pull/386 -* PR without clippy warmings in file changed tab by @billy1624 in https://github.com/SeaQL/sea-orm/pull/401 +* PR without clippy warnings in file changed tab by @billy1624 in https://github.com/SeaQL/sea-orm/pull/401 * Rename `sea-strum` lib back to `strum` by @billy1624 in https://github.com/SeaQL/sea-orm/pull/361 ### Breaking Changes @@ -286,7 +286,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph ### Fixed Issues * Delete::many() doesn't work when schema_name is defined https://github.com/SeaQL/sea-orm/issues/362 * find_with_related panic https://github.com/SeaQL/sea-orm/issues/374 -* How to define rust type of TIMESTAMP? https://github.com/SeaQL/sea-orm/issues/344 +* How to define the rust type of TIMESTAMP? https://github.com/SeaQL/sea-orm/issues/344 * Add Table on the generated Column enum https://github.com/SeaQL/sea-orm/issues/356 ### Merged PRs @@ -328,7 +328,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Codegen fix clippy warnings by @billy1624 in https://github.com/SeaQL/sea-orm/pull/303 * Add axum example by @YoshieraHuang in https://github.com/SeaQL/sea-orm/pull/297 * Enumeration by @billy1624 in https://github.com/SeaQL/sea-orm/pull/258 -* Add `PaginatorTrait` and `CountTrait` for more constrains by @YoshieraHuang in https://github.com/SeaQL/sea-orm/pull/306 +* Add `PaginatorTrait` and `CountTrait` for more constraints by @YoshieraHuang in https://github.com/SeaQL/sea-orm/pull/306 * Continue `PaginatorTrait` by @billy1624 in https://github.com/SeaQL/sea-orm/pull/307 * Refactor `Schema` by @billy1624 in https://github.com/SeaQL/sea-orm/pull/309 * Detailed connection errors by @billy1624 in https://github.com/SeaQL/sea-orm/pull/312 @@ -339,7 +339,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph * Returning by @billy1624 in https://github.com/SeaQL/sea-orm/pull/292 ### Breaking Changes -* Refactor `paginate()` & `count()` utilities into `PaginatorTrait`. You can use the paginator as usual but you might need to import `PaginatorTrait` manually when upgrading from previous version. +* Refactor `paginate()` & `count()` utilities into `PaginatorTrait`. You can use the paginator as usual but you might need to import `PaginatorTrait` manually when upgrading from the previous version. ```rust use futures::TryStreamExt; use sea_orm::{entity::*, query::*, tests_cfg::cake}; @@ -353,7 +353,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph // Do something on cakes: Vec } ``` -* The helper struct `Schema` converting `EntityTrait` into different `sea-query` statement now has to be initialized with `DbBackend`. +* The helper struct `Schema` converting `EntityTrait` into different `sea-query` statements now has to be initialized with `DbBackend`. ```rust use sea_orm::{tests_cfg::*, DbBackend, Schema}; use sea_orm::sea_query::TableCreateStatement; @@ -425,7 +425,7 @@ In this minor release, we removed `time` v0.1 from the dependency graph (We are changing our Changelog format from now on) ### Fixed Issues -* Align case trasforms across derive macros https://github.com/SeaQL/sea-orm/issues/262 +* Align case transforms across derive macros https://github.com/SeaQL/sea-orm/issues/262 * Added `is_null` and `is_not_null` to `ColumnTrait` https://github.com/SeaQL/sea-orm/issues/267 (The following is generated by GitHub) @@ -523,7 +523,7 @@ https://www.sea-ql.org/SeaORM/blog/2021-10-01-whats-new-in-0.2.4 - [[#191]] [sea-orm-cli] Unique key handling - [[#182]] `find_linked` join with alias - [[#202]] Accept both `postgres://` and `postgresql://` -- [[#208]] Support feteching T, (T, U), (T, U, P) etc +- [[#208]] Support fetching T, (T, U), (T, U, P) etc - [[#209]] Rename column name & column enum variant - [[#207]] Support `chrono::NaiveDate` & `chrono::NaiveTime` - Support `Condition::not` (from sea-query) @@ -539,7 +539,7 @@ https://www.sea-ql.org/SeaORM/blog/2021-10-01-whats-new-in-0.2.4 ## 0.2.3 - 2021-09-22 - [[#152]] DatabaseConnection impl `Clone` -- [[#175]] Impl `TryGetableMany` for diffrent types of generics +- [[#175]] Impl `TryGetableMany` for different types of generics - Codegen `TimestampWithTimeZone` fixup [#152]: https://github.com/SeaQL/sea-orm/issues/152 diff --git a/COMMUNITY.md b/COMMUNITY.md index 43d66c50..8d1fbbe2 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -37,14 +37,14 @@ If you have built an app using SeaORM and want to showcase it, feel free to open - [JinShu](https://github.com/gengteng/jinshu) | A cross-platform **I**nstant **M**essaging system written in 🦀 - [rust-juniper-playground](https://github.com/Yama-Tomo/rust-juniper-playground) | juniper with SeaORM example - [Oura Postgres Sink](https://github.com/dcSpark/oura-postgres-sink) | Sync a postgres database with the cardano blockchain using [Oura](https://github.com/txpipe/oura) -- [pansy](https://github.com/niuhuan/pansy) | An illustrations app using SeaORM, SQLite, flutter. runs on the desktop and mobile terminals -- [Orca](https://github.com/workfoxes/orca) | An No-code Test Automation platfrom using actix, SeaORM, react. runs on the desktop and cloud +- [pansy](https://github.com/niuhuan/pansy) | An illustration app using SeaORM, SQLite, flutter. runs on the desktop and mobile terminals +- [Orca](https://github.com/workfoxes/orca) | An No-code Test Automation platform using actix, SeaORM, react. runs on the desktop and cloud - [symbols](https://github.com/nappa85/symbols) | A proc-macro utility to populates enum variants with primary keys values - [Warpgate](https://github.com/warp-tech/warpgate) ![GitHub stars](https://img.shields.io/github/stars/warp-tech/warpgate.svg?style=social) | Smart SSH bastion that works with any SSH client - [suzuya](https://github.com/SH11235/suzuya) | A merchandise management application using SeaORM, Actix-Web, Tera - [snmp-sim-rust](https://github.com/sonalake/snmp-sim-rust) | SNMP Simulator - [template_flow](https://github.com/hilary888/template_flow) | An experiment exploring replacing placeholders in pre-prepared templates with their actual values -- [poem_admin](https://github.com/lingdu1234/poem_admin) | An admin panel built with poem, **Sea-orm** and Vue 3. +- [poem_admin](https://github.com/lingdu1234/poem_admin) | An admin panel built with poems, **Sea-orm** and Vue 3. - [VeryRezsi](https://github.com/szattila98/veryrezsi) | VeryRezsi is a subscription and expense calculator web-application. Powered by SvelteKit on client side, and Rust on server side. - [todo-rs](https://github.com/anshulxyz/todo-rs/) | A TUI ToDo-app written in Rust using Cursive library and SeaORM for SQLite - [KrakenPics](https://github.com/kraken-pics/backend) | A public file host written in rust using seaorm & actix_web @@ -59,7 +59,7 @@ If you have built an app using SeaORM and want to showcase it, feel free to open ## Learning Resources -If you have article, tutorial, podcast or video reated to SeaORM and want to share it with the community, feel free to submit a PR and add it to the list below! +If you have an article, tutorial, podcast or video related to SeaORM and want to share it with the community, feel free to submit a PR and add it to the list below! ### Tutorials diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76a51be9..e2907156 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,11 +10,11 @@ This project is governed by the [SeaQL Code of Conduct](https://github.com/SeaQL ## I have a question -If you got a question to ask, please do not open an issue for it. It's quicker to ask us on [SeaQL Discord Server](https://discord.com/invite/uCPdDXzbdv) or open a [GitHub Discussion](https://docs.github.com/en/discussions/quickstart#creating-a-new-discussion) on the corresponding repository. +If you have a question to ask, please do not open an issue for it. It's quicker to ask us on [SeaQL Discord Server](https://discord.com/invite/uCPdDXzbdv) or open a [GitHub Discussion](https://docs.github.com/en/discussions/quickstart#creating-a-new-discussion) on the corresponding repository. ## I need a feature -Feature request from anyone is definitely welcomed! Actually, since 0.2, many features are proposed and/or contributed by non-core members, e.g. [#105](https://github.com/SeaQL/sea-orm/issues/105), [#142](https://github.com/SeaQL/sea-orm/issues/142), [#252](https://github.com/SeaQL/sea-orm/issues/252), with various degrees of involvement. We will implement feature proposals if it benefits everyone, but of course code contributions will more likely be accepted. +Feature requests from anyone is definitely welcomed! Actually, since 0.2, many features are proposed and/or contributed by non-core members, e.g. [#105](https://github.com/SeaQL/sea-orm/issues/105), [#142](https://github.com/SeaQL/sea-orm/issues/142), [#252](https://github.com/SeaQL/sea-orm/issues/252), with various degrees of involvement. We will implement feature proposals if it benefits everyone, but of course code contributions will more likely be accepted. ## I want to support @@ -22,7 +22,7 @@ Awesome! The best way to support us is to recommend it to your classmates/collea ## I want to join -We are always looking for long-term contributors. If you want to commit longer-term to SeaQL's open source effort, definitely talk with us! There may be various form of "grant" to compensate your devotion. Although at this stage we are not resourceful enough to offer a stable stream of income to contributors. +We are always looking for long-term contributors. If you want to commit longer-term to SeaQL's open source effort, definitely talk with us! There may be various forms of "grant" to compensate for your devotion. Although at this stage we are not resourceful enough to offer a stable stream of income to contributors. ## I want to sponsor @@ -51,7 +51,7 @@ Without involving a live database, you can run unit tests on your machine with t ### Integration Test -Next, if you want to run integration tests on a live database. We recommand using Docker to spawn your database instance, you can refer to [this](build-tools/docker-compose.yml) docker compose file for reference. +Next, if you want to run integration tests on a live database. We recommend using Docker to spawn your database instance, you can refer to [this](build-tools/docker-compose.yml) docker compose file for reference. Running integration tests on a live database: - SQLite diff --git a/DESIGN.md b/DESIGN.md index 291efc02..4cfc2255 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,7 +16,7 @@ Avoid macros with DSL, use derive macros where appropriate. Be friendly with IDE ## Test Time -After some bitterness we realized it is not possible to capture everything compile time. But we don't +After some bitterness we realized it is not possible to capture everything at compile time. But we don't want to encounter problems at run time either. The solution is to perform checking at 'test time' to uncover problems. These checks will be removed at production so there will be no run time penalty. @@ -96,4 +96,4 @@ where let a: ActiveModel = a.into_active_model(); ... } -``` \ No newline at end of file +``` From aecdeafeee0e337db7a2e6103a13622b2bbfc1c7 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Wed, 28 Sep 2022 00:15:19 +0800 Subject: [PATCH 41/71] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eccdba9..b679b55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 * Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 +### House keeping + +* Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 ## 0.9.2 - 2022-08-20 From 9eacecd3644de5791d05c49413cfc05ab55148f9 Mon Sep 17 00:00:00 2001 From: Nick Burrett Date: Thu, 29 Sep 2022 03:47:08 +0100 Subject: [PATCH 42/71] FromJsonQueryResult: add missing module prefix to sea_query (#1081) --- sea-orm-macros/src/derives/try_getable_from_json.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sea-orm-macros/src/derives/try_getable_from_json.rs b/sea-orm-macros/src/derives/try_getable_from_json.rs index 957cee3d..42fcbf21 100644 --- a/sea-orm-macros/src/derives/try_getable_from_json.rs +++ b/sea-orm-macros/src/derives/try_getable_from_json.rs @@ -14,7 +14,7 @@ pub fn expand_derive_from_json_query_result(ident: Ident) -> syn::Result Result { match v { sea_orm::Value::Json(Some(json)) => Ok( From 49c1a6d716c781670c10ea3b66671338921ce0cc Mon Sep 17 00:00:00 2001 From: Horu <73709188+HigherOrderLogic@users.noreply.github.com> Date: Mon, 3 Oct 2022 11:12:39 +0700 Subject: [PATCH 43/71] docs(readme): fix architecture link (#1086) --- README.md | 2 +- src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d912b5fe..eade3e56 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ fruit::Entity::delete_many() ## Learn More 1. [Design](https://github.com/SeaQL/sea-orm/tree/master/DESIGN.md) -1. [Architecture](https://github.com/SeaQL/sea-orm/tree/master/ARCHITECTURE.md) +1. [Architecture](https://www.sea-ql.org/SeaORM/docs/internal-design/architecture/) 1. [Release Model](https://www.sea-ql.org/SeaORM/blog/2021-08-30-release-model) 1. [Change Log](https://github.com/SeaQL/sea-orm/tree/master/CHANGELOG.md) diff --git a/src/lib.rs b/src/lib.rs index 8011ea0b..2162fcc5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -259,7 +259,7 @@ //! ## Learn More //! //! 1. [Design](https://github.com/SeaQL/sea-orm/tree/master/DESIGN.md) -//! 1. [Architecture](https://github.com/SeaQL/sea-orm/tree/master/ARCHITECTURE.md) +//! 1. [Architecture](https://www.sea-ql.org/SeaORM/docs/internal-design/architecture/) //! 1. [Release Model](https://www.sea-ql.org/SeaORM/blog/2021-08-30-release-model) //! 1. [Change Log](https://github.com/SeaQL/sea-orm/tree/master/CHANGELOG.md) //! From d9ac2f150913a9314bfe13978629b4df1388af20 Mon Sep 17 00:00:00 2001 From: Horu <73709188+HigherOrderLogic@users.noreply.github.com> Date: Wed, 5 Oct 2022 22:19:48 +0700 Subject: [PATCH 44/71] fix(deps): dotenv -> dotenvy (#1085) --- examples/actix3_example/api/Cargo.toml | 2 +- examples/actix3_example/api/src/lib.rs | 2 +- examples/actix_example/api/Cargo.toml | 2 +- examples/actix_example/api/src/lib.rs | 2 +- examples/axum_example/api/Cargo.toml | 2 +- examples/axum_example/api/src/lib.rs | 2 +- examples/graphql_example/api/Cargo.toml | 2 +- examples/graphql_example/api/src/lib.rs | 2 +- examples/graphql_example/migration/Cargo.toml | 2 +- examples/graphql_example/migration/src/main.rs | 2 +- examples/jsonrpsee_example/api/Cargo.toml | 2 +- examples/jsonrpsee_example/api/src/lib.rs | 2 +- examples/poem_example/api/Cargo.toml | 2 +- examples/poem_example/api/src/lib.rs | 2 +- examples/salvo_example/api/Cargo.toml | 2 +- examples/salvo_example/api/src/lib.rs | 2 +- issues/471/Cargo.toml | 2 +- issues/471/src/main.rs | 2 +- issues/693/Cargo.toml | 2 +- sea-orm-cli/Cargo.toml | 2 +- sea-orm-cli/src/bin/main.rs | 2 +- sea-orm-cli/src/bin/sea.rs | 2 +- sea-orm-migration/Cargo.toml | 2 +- sea-orm-migration/src/cli.rs | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/examples/actix3_example/api/Cargo.toml b/examples/actix3_example/api/Cargo.toml index 5f38712e..16160b49 100644 --- a/examples/actix3_example/api/Cargo.toml +++ b/examples/actix3_example/api/Cargo.toml @@ -14,7 +14,7 @@ actix-files = "0.5" futures = { version = "^0.3" } futures-util = { version = "^0.3" } tera = "1.8.0" -dotenv = "0.15" +dotenvy = "0.15" listenfd = "0.3.3" serde = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/actix3_example/api/src/lib.rs b/examples/actix3_example/api/src/lib.rs index 275e4a7c..e35d88d2 100644 --- a/examples/actix3_example/api/src/lib.rs +++ b/examples/actix3_example/api/src/lib.rs @@ -168,7 +168,7 @@ async fn start() -> std::io::Result<()> { tracing_subscriber::fmt::init(); // get env vars - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let host = env::var("HOST").expect("HOST is not set in .env file"); let port = env::var("PORT").expect("PORT is not set in .env file"); diff --git a/examples/actix_example/api/Cargo.toml b/examples/actix_example/api/Cargo.toml index 078e5561..4a6727e9 100644 --- a/examples/actix_example/api/Cargo.toml +++ b/examples/actix_example/api/Cargo.toml @@ -13,7 +13,7 @@ actix-rt = "2.7" actix-service = "2" actix-web = "4" tera = "1.15.0" -dotenv = "0.15" +dotenvy = "0.15" listenfd = "0.5" serde = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/actix_example/api/src/lib.rs b/examples/actix_example/api/src/lib.rs index f95200e8..64a909a9 100644 --- a/examples/actix_example/api/src/lib.rs +++ b/examples/actix_example/api/src/lib.rs @@ -160,7 +160,7 @@ async fn start() -> std::io::Result<()> { tracing_subscriber::fmt::init(); // get env vars - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let host = env::var("HOST").expect("HOST is not set in .env file"); let port = env::var("PORT").expect("PORT is not set in .env file"); diff --git a/examples/axum_example/api/Cargo.toml b/examples/axum_example/api/Cargo.toml index dc1f5ce4..caa1cb8c 100644 --- a/examples/axum_example/api/Cargo.toml +++ b/examples/axum_example/api/Cargo.toml @@ -13,7 +13,7 @@ tower = "0.4.12" tower-http = { version = "0.3.3", features = ["fs"] } tower-cookies = "0.6.0" anyhow = "1.0.57" -dotenv = "0.15.0" +dotenvy = "0.15.0" serde = "1.0.137" serde_json = "1.0.81" tera = "1.15.0" diff --git a/examples/axum_example/api/src/lib.rs b/examples/axum_example/api/src/lib.rs index ead63e7b..edeee661 100644 --- a/examples/axum_example/api/src/lib.rs +++ b/examples/axum_example/api/src/lib.rs @@ -27,7 +27,7 @@ async fn start() -> anyhow::Result<()> { env::set_var("RUST_LOG", "debug"); tracing_subscriber::fmt::init(); - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let host = env::var("HOST").expect("HOST is not set in .env file"); let port = env::var("PORT").expect("PORT is not set in .env file"); diff --git a/examples/graphql_example/api/Cargo.toml b/examples/graphql_example/api/Cargo.toml index e4526410..a4fd78f4 100644 --- a/examples/graphql_example/api/Cargo.toml +++ b/examples/graphql_example/api/Cargo.toml @@ -9,7 +9,7 @@ publish = false graphql-example-core = { path = "../core" } tokio = { version = "1.0", features = ["full"] } axum = "^0.5.1" -dotenv = "0.15.0" +dotenvy = "0.15.0" async-graphql-axum = "^4.0.6" entity = { path = "../entity" } migration = { path = "../migration" } diff --git a/examples/graphql_example/api/src/lib.rs b/examples/graphql_example/api/src/lib.rs index 42e63e1c..85275fcb 100644 --- a/examples/graphql_example/api/src/lib.rs +++ b/examples/graphql_example/api/src/lib.rs @@ -14,7 +14,7 @@ use axum::{ use graphql::schema::{build_schema, AppSchema}; #[cfg(debug_assertions)] -use dotenv::dotenv; +use dotenvy::dotenv; async fn graphql_handler(schema: Extension, req: GraphQLRequest) -> GraphQLResponse { schema.execute(req.into_inner()).await.into() diff --git a/examples/graphql_example/migration/Cargo.toml b/examples/graphql_example/migration/Cargo.toml index 4d42e621..f63e03ba 100644 --- a/examples/graphql_example/migration/Cargo.toml +++ b/examples/graphql_example/migration/Cargo.toml @@ -9,7 +9,7 @@ name = "migration" path = "src/lib.rs" [dependencies] -dotenv = "0.15.0" +dotenvy = "0.15.0" async-std = { version = "^1", features = ["attributes", "tokio1"] } [dependencies.sea-orm-migration] diff --git a/examples/graphql_example/migration/src/main.rs b/examples/graphql_example/migration/src/main.rs index 37517f25..8678e0ad 100644 --- a/examples/graphql_example/migration/src/main.rs +++ b/examples/graphql_example/migration/src/main.rs @@ -1,7 +1,7 @@ use sea_orm_migration::prelude::*; #[cfg(debug_assertions)] -use dotenv::dotenv; +use dotenvy::dotenv; #[async_std::main] async fn main() { diff --git a/examples/jsonrpsee_example/api/Cargo.toml b/examples/jsonrpsee_example/api/Cargo.toml index 51c959e6..7f84ecd3 100644 --- a/examples/jsonrpsee_example/api/Cargo.toml +++ b/examples/jsonrpsee_example/api/Cargo.toml @@ -10,7 +10,7 @@ jsonrpsee = { version = "^0.8.0", features = ["full"] } jsonrpsee-core = "0.9.0" tokio = { version = "1.8.0", features = ["full"] } serde = { version = "1", features = ["derive"] } -dotenv = "0.15" +dotenvy = "0.15" entity = { path = "../entity" } migration = { path = "../migration" } anyhow = "1.0.52" diff --git a/examples/jsonrpsee_example/api/src/lib.rs b/examples/jsonrpsee_example/api/src/lib.rs index f98cc183..584c5065 100644 --- a/examples/jsonrpsee_example/api/src/lib.rs +++ b/examples/jsonrpsee_example/api/src/lib.rs @@ -102,7 +102,7 @@ async fn start() -> std::io::Result<()> { ); // get env vars - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let host = env::var("HOST").expect("HOST is not set in .env file"); let port = env::var("PORT").expect("PORT is not set in .env file"); diff --git a/examples/poem_example/api/Cargo.toml b/examples/poem_example/api/Cargo.toml index 379bc181..00211749 100644 --- a/examples/poem_example/api/Cargo.toml +++ b/examples/poem_example/api/Cargo.toml @@ -10,6 +10,6 @@ poem = { version = "1.2.33", features = ["static-files"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } tera = "1.8.0" -dotenv = "0.15" +dotenvy = "0.15" entity = { path = "../entity" } migration = { path = "../migration" } diff --git a/examples/poem_example/api/src/lib.rs b/examples/poem_example/api/src/lib.rs index 3dc8fb03..34eacdcd 100644 --- a/examples/poem_example/api/src/lib.rs +++ b/examples/poem_example/api/src/lib.rs @@ -126,7 +126,7 @@ async fn start() -> std::io::Result<()> { tracing_subscriber::fmt::init(); // get env vars - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let host = env::var("HOST").expect("HOST is not set in .env file"); let port = env::var("PORT").expect("PORT is not set in .env file"); diff --git a/examples/salvo_example/api/Cargo.toml b/examples/salvo_example/api/Cargo.toml index cc26a774..6f329625 100644 --- a/examples/salvo_example/api/Cargo.toml +++ b/examples/salvo_example/api/Cargo.toml @@ -10,6 +10,6 @@ salvo = { version = "0.27", features = ["affix", "serve-static"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } tera = "1.8.0" -dotenv = "0.15" +dotenvy = "0.15" entity = { path = "../entity" } migration = { path = "../migration" } diff --git a/examples/salvo_example/api/src/lib.rs b/examples/salvo_example/api/src/lib.rs index 22508055..70a74e31 100644 --- a/examples/salvo_example/api/src/lib.rs +++ b/examples/salvo_example/api/src/lib.rs @@ -148,7 +148,7 @@ pub async fn main() { tracing_subscriber::fmt::init(); // get env vars - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let host = env::var("HOST").expect("HOST is not set in .env file"); let port = env::var("PORT").expect("PORT is not set in .env file"); diff --git a/issues/471/Cargo.toml b/issues/471/Cargo.toml index dffc14c6..5640366f 100644 --- a/issues/471/Cargo.toml +++ b/issues/471/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] tokio = { version = "1.14", features = ["full"] } anyhow = "1" -dotenv = "0.15" +dotenvy = "0.15" futures-util = "0.3" serde = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/issues/471/src/main.rs b/issues/471/src/main.rs index 9d7f664d..d4e93a95 100644 --- a/issues/471/src/main.rs +++ b/issues/471/src/main.rs @@ -11,7 +11,7 @@ async fn main() -> anyhow::Result<()> { env::set_var("RUST_LOG", "debug"); tracing_subscriber::fmt::init(); - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"); let db = Database::connect(db_url) .await diff --git a/issues/693/Cargo.toml b/issues/693/Cargo.toml index acd1bb0e..8ec2caee 100644 --- a/issues/693/Cargo.toml +++ b/issues/693/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] tokio = { version = "1.14", features = ["full"] } anyhow = "1" -dotenv = "0.15" +dotenvy = "0.15" futures-util = "0.3" serde = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/sea-orm-cli/Cargo.toml b/sea-orm-cli/Cargo.toml index f1e26a56..3885e5a1 100644 --- a/sea-orm-cli/Cargo.toml +++ b/sea-orm-cli/Cargo.toml @@ -32,7 +32,7 @@ required-features = ["codegen"] [dependencies] clap = { version = "^3.2", features = ["env", "derive"] } -dotenv = { version = "^0.15" } +dotenvy = { version = "^0.15" } async-std = { version = "^1.9", features = [ "attributes", "tokio1" ] } sea-orm-codegen = { version = "^0.10.0", path = "../sea-orm-codegen", optional = true } sea-schema = { version = "^0.9.3" } diff --git a/sea-orm-cli/src/bin/main.rs b/sea-orm-cli/src/bin/main.rs index ce41c616..e9d40401 100644 --- a/sea-orm-cli/src/bin/main.rs +++ b/sea-orm-cli/src/bin/main.rs @@ -1,5 +1,5 @@ use clap::StructOpt; -use dotenv::dotenv; +use dotenvy::dotenv; use sea_orm_cli::{handle_error, run_generate_command, run_migrate_command, Cli, Commands}; #[async_std::main] diff --git a/sea-orm-cli/src/bin/sea.rs b/sea-orm-cli/src/bin/sea.rs index cc945f95..4a8b3d14 100644 --- a/sea-orm-cli/src/bin/sea.rs +++ b/sea-orm-cli/src/bin/sea.rs @@ -1,7 +1,7 @@ //! COPY FROM bin/main.rs use clap::StructOpt; -use dotenv::dotenv; +use dotenvy::dotenv; use sea_orm_cli::{handle_error, run_generate_command, run_migrate_command, Cli, Commands}; #[async_std::main] diff --git a/sea-orm-migration/Cargo.toml b/sea-orm-migration/Cargo.toml index 2e358502..4abed040 100644 --- a/sea-orm-migration/Cargo.toml +++ b/sea-orm-migration/Cargo.toml @@ -22,7 +22,7 @@ path = "src/lib.rs" [dependencies] async-trait = { version = "^0.1" } clap = { version = "^3.2", features = ["env", "derive"] } -dotenv = { version = "^0.15" } +dotenvy = { version = "^0.15" } sea-orm = { version = "^0.10.0", path = "../", default-features = false, features = ["macros"] } sea-orm-cli = { version = "^0.10.0", path = "../sea-orm-cli", default-features = false } sea-schema = { version = "^0.9.3" } diff --git a/sea-orm-migration/src/cli.rs b/sea-orm-migration/src/cli.rs index de228c40..d098cb81 100644 --- a/sea-orm-migration/src/cli.rs +++ b/sea-orm-migration/src/cli.rs @@ -1,5 +1,5 @@ use clap::Parser; -use dotenv::dotenv; +use dotenvy::dotenv; use std::{error::Error, fmt::Display, process::exit}; use tracing_subscriber::{prelude::*, EnvFilter}; From 234d35902c1e47004d4af53c929bc200eefedd05 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Wed, 5 Oct 2022 23:39:42 +0800 Subject: [PATCH 45/71] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b679b55b..508beaa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### House keeping * Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 +* Replace `dotenv` with `dotenvy` in examples https://github.com/SeaQL/sea-orm/pull/1085 ## 0.9.2 - 2022-08-20 From 45840990211373aa799384ba3175c582d51b373f Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Wed, 5 Oct 2022 23:41:05 +0800 Subject: [PATCH 46/71] Update CHANGELOG.md --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 508beaa8..bd868ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 * Replace `dotenv` with `dotenvy` in examples https://github.com/SeaQL/sea-orm/pull/1085 +## 0.9.3 - 2022-09-30 + +### Enhancements + +* `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 +* Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 +* Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 +* Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 + +### Bug fixes + +* Implement `IntoActiveValue` for `time` types https://github.com/SeaQL/sea-orm/pull/1041 +* Fixed module import for `FromJsonQueryResult` derive macro https://github.com/SeaQL/sea-orm/pull/1081 + ## 0.9.2 - 2022-08-20 ### Enhancements From 0bf74a95fa0eaceb06f83bd933370a420daf1f33 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 6 Oct 2022 18:09:04 +0800 Subject: [PATCH 47/71] Add database tags to projects that built with SeaORM --- COMMUNITY.md | 78 ++++++++++++++++++++++++++-------------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/COMMUNITY.md b/COMMUNITY.md index 8d1fbbe2..e49fa917 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -7,55 +7,55 @@ If you have built an app using SeaORM and want to showcase it, feel free to open ### Startups - [Caido](https://caido.io/) | A lightweight web security auditing toolkit -- [Svix](https://www.svix.com/) ([repository](https://github.com/svix/svix-webhooks)) ![GitHub stars](https://img.shields.io/github/stars/svix/svix-webhooks.svg?style=social) | The enterprise ready webhooks service -- [Sensei](https://l2.technology/sensei) ([repository](https://github.com/L2-Technology/sensei)) ![GitHub stars](https://img.shields.io/github/stars/L2-Technology/sensei.svg?style=social) | A Bitcoin lightning node implementation -- [Spyglass](https://docs.spyglass.fyi/) ([repository](https://github.com/a5huynh/spyglass)) ![GitHub stars](https://img.shields.io/github/stars/a5huynh/spyglass.svg?style=social) | 🔭 A personal search engine that indexes what you want w/ a simple set of rules. +- [Svix](https://www.svix.com/) ([repository](https://github.com/svix/svix-webhooks)) ![GitHub stars](https://img.shields.io/github/stars/svix/svix-webhooks.svg?style=social) | The enterprise ready webhooks service | DB: Postgres +- [Sensei](https://l2.technology/sensei) ([repository](https://github.com/L2-Technology/sensei)) ![GitHub stars](https://img.shields.io/github/stars/L2-Technology/sensei.svg?style=social) | A Bitcoin lightning node implementation | DB: MySQL, Postgres, SQLite +- [Spyglass](https://docs.spyglass.fyi/) ([repository](https://github.com/a5huynh/spyglass)) ![GitHub stars](https://img.shields.io/github/stars/a5huynh/spyglass.svg?style=social) | 🔭 A personal search engine that indexes what you want w/ a simple set of rules. | DB: SQLite - [My Data My Consent](https://mydatamyconsent.com/) | Online data sharing for people and businesses simplified -- [CodeCTRL](https://codectrl.authentura.com) ([repository](https://github.com/Authentura/codectrl)) | A self-hostable code logging platform +- [CodeCTRL](https://codectrl.authentura.com) ([repository](https://github.com/Authentura/codectrl)) | A self-hostable code logging platform | DB: SQLite ### Frameworks -- [awto](https://github.com/awto-rs/awto) | Awtomate your 🦀 microservices with awto -- [tardis](https://github.com/ideal-world/tardis) | Elegant, Clean Rust development framework🛸 -- [Rust Async-GraphQL Example: Caster API](https://github.com/bkonkle/rust-example-caster-api) | A demo GraphQL API using Tokio, Warp, async-graphql, and SeaOrm -- [Quasar](https://github.com/Technik97/Quasar) | Rust REST API using Actix-Web, SeaOrm and Postgres with Svelte Typescript Frontend -- [actix-react-starter-template](https://github.com/aslamplr/actix-react-starter-template) | Actix web + SeaORM + React + Redux + Redux Saga project starter template +- [awto](https://github.com/awto-rs/awto) | Awtomate your 🦀 microservices with awto | DB: Postgres +- [tardis](https://github.com/ideal-world/tardis) | Elegant, Clean Rust development framework🛸 | DB: MySQL, Postgres, SQLite +- [Rust Async-GraphQL Example: Caster API](https://github.com/bkonkle/rust-example-caster-api) | A demo GraphQL API using Tokio, Warp, async-graphql, and SeaOrm | DB: Postgres +- [Quasar](https://github.com/Technik97/Quasar) | Rust REST API using Actix-Web, SeaOrm and Postgres with Svelte Typescript Frontend | DB: Postgres +- [actix-react-starter-template](https://github.com/aslamplr/actix-react-starter-template) | Actix web + SeaORM + React + Redux + Redux Saga project starter template | DB: Postgres - [ZAPP](https://zapp.epics.dev) ([repository](https://github.com/EpicsDAO/zapp)) | ZAPP is a serverless framework made by Rust. Quickly build a scalable GraphQL API web server. ### Open Source Projects -- [Wikijump](https://github.com/scpwiki/wikijump) ([repository](https://github.com/scpwiki/wikijump/tree/develop/deepwell)) | API service for Wikijump, a fork of Wikidot -- [aeroFans](https://github.com/naryand/aerofans) | Full stack forum-like social media platform in Rust and WebAssembly -- [thrpg](https://github.com/thrpg/thrpg) | Touhou Project's secondary creative games -- [Adta](https://github.com/aaronleopold/adta) | Adta is **A**nother **D**amn **T**odo **A**pp, fun little side project -- [Axum-Book-Management](https://github.com/lz1998/axum-book-management) | CRUD system of book-management with ORM and JWT for educational purposes -- [mediarepo](https://mediarepo.trivernis.dev) ([repository](https://github.com/Trivernis/mediarepo)) | A tag-based media management application -- [THUBurrow](https://thuburrow.com) ([repository](https://github.com/BobAnkh/THUBurrow)) | A campus forum built by Next.js and Rocket -- [Backpack](https://github.com/JSH32/Backpack) | Open source self hosted file sharing platform on crack +- [Wikijump](https://github.com/scpwiki/wikijump) ([repository](https://github.com/scpwiki/wikijump/tree/develop/deepwell)) | API service for Wikijump, a fork of Wikidot | DB: Postgres +- [aeroFans](https://github.com/naryand/aerofans) | Full stack forum-like social media platform in Rust and WebAssembly | DB: Postgres +- [thrpg](https://github.com/thrpg/thrpg) | Touhou Project's secondary creative games | DB: Postgres +- [Adta](https://github.com/aaronleopold/adta) | Adta is **A**nother **D**amn **T**odo **A**pp, fun little side project | DB: MySQL, Postgres, SQLite +- [Axum-Book-Management](https://github.com/lz1998/axum-book-management) | CRUD system of book-management with ORM and JWT for educational purposes | DB: MySQL +- [mediarepo](https://mediarepo.trivernis.dev) ([repository](https://github.com/Trivernis/mediarepo)) | A tag-based media management application | DB: SQLite +- [THUBurrow](https://thuburrow.com) ([repository](https://github.com/BobAnkh/THUBurrow)) | A campus forum built by Next.js and Rocket | DB: Postgres +- [Backpack](https://github.com/JSH32/Backpack) | Open source self hosted file sharing platform on crack | DB: MySQL, Postgres, SQLite - [Stump](https://github.com/aaronleopold/stump) ![GitHub stars](https://img.shields.io/github/stars/aaronleopold/stump.svg?style=social) | A free and open source comics server with OPDS support -- [mugen](https://github.com/koopa1338/mugen-dms) | DMS written in 🦀 -- [JinShu](https://github.com/gengteng/jinshu) | A cross-platform **I**nstant **M**essaging system written in 🦀 -- [rust-juniper-playground](https://github.com/Yama-Tomo/rust-juniper-playground) | juniper with SeaORM example -- [Oura Postgres Sink](https://github.com/dcSpark/oura-postgres-sink) | Sync a postgres database with the cardano blockchain using [Oura](https://github.com/txpipe/oura) -- [pansy](https://github.com/niuhuan/pansy) | An illustration app using SeaORM, SQLite, flutter. runs on the desktop and mobile terminals -- [Orca](https://github.com/workfoxes/orca) | An No-code Test Automation platform using actix, SeaORM, react. runs on the desktop and cloud -- [symbols](https://github.com/nappa85/symbols) | A proc-macro utility to populates enum variants with primary keys values -- [Warpgate](https://github.com/warp-tech/warpgate) ![GitHub stars](https://img.shields.io/github/stars/warp-tech/warpgate.svg?style=social) | Smart SSH bastion that works with any SSH client -- [suzuya](https://github.com/SH11235/suzuya) | A merchandise management application using SeaORM, Actix-Web, Tera -- [snmp-sim-rust](https://github.com/sonalake/snmp-sim-rust) | SNMP Simulator -- [template_flow](https://github.com/hilary888/template_flow) | An experiment exploring replacing placeholders in pre-prepared templates with their actual values -- [poem_admin](https://github.com/lingdu1234/poem_admin) | An admin panel built with poems, **Sea-orm** and Vue 3. -- [VeryRezsi](https://github.com/szattila98/veryrezsi) | VeryRezsi is a subscription and expense calculator web-application. Powered by SvelteKit on client side, and Rust on server side. -- [todo-rs](https://github.com/anshulxyz/todo-rs/) | A TUI ToDo-app written in Rust using Cursive library and SeaORM for SQLite -- [KrakenPics](https://github.com/kraken-pics/backend) | A public file host written in rust using seaorm & actix_web -- [service_auth](https://github.com/shorii/service_auth) | A simple JWT authentication web-application -- [rj45less-server](https://github.com/pmnxis/rj45less-server) | A simple unique number allocator for custom router -- [SophyCore](https://github.com/FarDragi/SophyCore) | Main system that centralizes all rules, to be used by both the discord bot and the future site +- [mugen](https://github.com/koopa1338/mugen-dms) | DMS written in 🦀 | DB: Postgres +- [JinShu](https://github.com/gengteng/jinshu) | A cross-platform **I**nstant **M**essaging system written in 🦀 | DB: MySQL, Postgres +- [rust-juniper-playground](https://github.com/Yama-Tomo/rust-juniper-playground) | juniper with SeaORM example | DB: MySQL +- [Oura Postgres Sink](https://github.com/dcSpark/oura-postgres-sink) | Sync a postgres database with the cardano blockchain using [Oura](https://github.com/txpipe/oura) | DB: Postgres +- [pansy](https://github.com/niuhuan/pansy) | An illustration app using SeaORM, SQLite, flutter. runs on the desktop and mobile terminals | DB: SQLite +- [Orca](https://github.com/workfoxes/orca) | An No-code Test Automation platform using actix, SeaORM, react. runs on the desktop and cloud | DB: Postgres +- [symbols](https://github.com/nappa85/symbols) | A proc-macro utility to populates enum variants with primary keys values | DB: MySQL +- [Warpgate](https://github.com/warp-tech/warpgate) ![GitHub stars](https://img.shields.io/github/stars/warp-tech/warpgate.svg?style=social) | Smart SSH bastion that works with any SSH client | DB: SQLite +- [suzuya](https://github.com/SH11235/suzuya) | A merchandise management application using SeaORM, Actix-Web, Tera | DB: Postgres +- [snmp-sim-rust](https://github.com/sonalake/snmp-sim-rust) | SNMP Simulator | DB: SQLite +- [template_flow](https://github.com/hilary888/template_flow) | An experiment exploring replacing placeholders in pre-prepared templates with their actual values | DB: Postgres +- [poem_admin](https://github.com/lingdu1234/poem_admin) | An admin panel built with poems, **Sea-orm** and Vue 3. | DB: MySQL, Postgres, SQLite +- [VeryRezsi](https://github.com/szattila98/veryrezsi) | VeryRezsi is a subscription and expense calculator web-application. Powered by SvelteKit on client side, and Rust on server side. | DB: MySQL +- [todo-rs](https://github.com/anshulxyz/todo-rs/) | A TUI ToDo-app written in Rust using Cursive library and SeaORM for SQLite | DB: SQLite +- [KrakenPics](https://github.com/kraken-pics/backend) | A public file host written in rust using seaorm & actix_web | DB: MySQL +- [service_auth](https://github.com/shorii/service_auth) | A simple JWT authentication web-application | DB: Postgres +- [rj45less-server](https://github.com/pmnxis/rj45less-server) | A simple unique number allocator for custom router | DB: SQLite +- [SophyCore](https://github.com/FarDragi/SophyCore) | Main system that centralizes all rules, to be used by both the discord bot and the future site | DB: Postgres - [lldap](https://github.com/nitnelave/lldap) ![GitHub stars](https://img.shields.io/github/stars/nitnelave/lldap.svg?style=social) | Light LDAP implementation for authentication -- [nitro_repo](https://github.com/wyatt-herkamp/nitro_repo) | An OpenSource, lightweight, and fast artifact manager. -- [MoonRamp](https://github.com/MoonRamp/MoonRamp) | A free and open source crypto payment gateway -- [url_shortener](https://github.com/michidk/url_shortener) | A simple self-hosted URL shortener written in Rust -- [RGB Lib](https://github.com/RGB-Tools/rgb-lib) | A library to manage wallets for RGB assets +- [nitro_repo](https://github.com/wyatt-herkamp/nitro_repo) | An OpenSource, lightweight, and fast artifact manager. | DB: MySQL, SQLite +- [MoonRamp](https://github.com/MoonRamp/MoonRamp) | A free and open source crypto payment gateway | DB: MySQL, Postgres, SQLite +- [url_shortener](https://github.com/michidk/url_shortener) | A simple self-hosted URL shortener written in Rust | DB: MySQL, Postgres, SQLite +- [RGB Lib](https://github.com/RGB-Tools/rgb-lib) | A library to manage wallets for RGB assets | DB: MySQL, Postgres, SQLite ## Learning Resources From 78904dcc6aa4e5173427947c1d7c0273e73cd8b0 Mon Sep 17 00:00:00 2001 From: p0rtL6 <80869244+p0rtL6@users.noreply.github.com> Date: Thu, 6 Oct 2022 15:25:17 +0000 Subject: [PATCH 48/71] Update COMMUNITY.md (#1095) --- COMMUNITY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/COMMUNITY.md b/COMMUNITY.md index e49fa917..e12fe1a1 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -56,6 +56,7 @@ If you have built an app using SeaORM and want to showcase it, feel free to open - [MoonRamp](https://github.com/MoonRamp/MoonRamp) | A free and open source crypto payment gateway | DB: MySQL, Postgres, SQLite - [url_shortener](https://github.com/michidk/url_shortener) | A simple self-hosted URL shortener written in Rust | DB: MySQL, Postgres, SQLite - [RGB Lib](https://github.com/RGB-Tools/rgb-lib) | A library to manage wallets for RGB assets | DB: MySQL, Postgres, SQLite +- [RCloud](https://github.com/p0rtL6/RCloud) | A self-hosted lightweight cloud drive alternative ## Learning Resources From 5f1670329d512e412b4eb05d453b81eab5d21dae Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 6 Oct 2022 23:38:45 +0800 Subject: [PATCH 49/71] Trim spaces when paginating raw SQL (#1094) --- src/executor/paginator.rs | 44 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/executor/paginator.rs b/src/executor/paginator.rs index a296efa5..08ceffbd 100644 --- a/src/executor/paginator.rs +++ b/src/executor/paginator.rs @@ -251,7 +251,7 @@ where { type Selector = S; fn paginate(self, db: &'db C, page_size: u64) -> Paginator<'db, C, S> { - let sql = &self.stmt.sql[6..]; + let sql = &self.stmt.sql.trim()[6..]; let mut query = SelectStatement::new(); query.expr(if let Some(values) = self.stmt.values { Expr::cust_with_values(sql, values.0) @@ -306,6 +306,7 @@ mod tests { use crate::{DatabaseConnection, DbBackend, MockDatabase, Transaction}; use futures::TryStreamExt; use once_cell::sync::Lazy; + use pretty_assertions::assert_eq; use sea_query::{Alias, Expr, SelectStatement, Value}; static RAW_STMT: Lazy = Lazy::new(|| { @@ -726,4 +727,45 @@ mod tests { assert_eq!(db.into_transaction_log(), Transaction::wrap(stmts)); Ok(()) } + + #[smol_potat::test] + async fn into_stream_raw_leading_spaces() -> Result<(), DbErr> { + let (db, pages) = setup(); + + let raw_stmt = Statement::from_sql_and_values( + DbBackend::Postgres, + r#" SELECT "fruit"."id", "fruit"."name", "fruit"."cake_id" FROM "fruit" "#, + vec![], + ); + + let mut fruit_stream = fruit::Entity::find() + .from_raw_sql(raw_stmt.clone()) + .paginate(&db, 2) + .into_stream(); + + assert_eq!(fruit_stream.try_next().await?, Some(pages[0].clone())); + assert_eq!(fruit_stream.try_next().await?, Some(pages[1].clone())); + assert_eq!(fruit_stream.try_next().await?, None); + + drop(fruit_stream); + + let mut select = SelectStatement::new() + .exprs(vec![ + Expr::tbl(fruit::Entity, fruit::Column::Id), + Expr::tbl(fruit::Entity, fruit::Column::Name), + Expr::tbl(fruit::Entity, fruit::Column::CakeId), + ]) + .from(fruit::Entity) + .to_owned(); + + let query_builder = db.get_database_backend(); + let stmts = vec![ + query_builder.build(select.clone().offset(0).limit(2)), + query_builder.build(select.clone().offset(2).limit(2)), + query_builder.build(select.offset(4).limit(2)), + ]; + + assert_eq!(db.into_transaction_log(), Transaction::wrap(stmts)); + Ok(()) + } } From 7eaac3843d25d58deef6f20a9ed01138cdd6e1bb Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 6 Oct 2022 23:42:01 +0800 Subject: [PATCH 50/71] Update CHANGELOG.md --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd868ee3..c4121bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Generate entity files as a library or module https://github.com/SeaQL/sea-orm/pull/953 * Generate a new migration template with name prefix of unix timestamp https://github.com/SeaQL/sea-orm/pull/947 -### Breaking changes - -* Replaced `usize` with `u64` in `PaginatorTrait` https://github.com/SeaQL/sea-orm/pull/789 - ### Enhancements * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 @@ -25,6 +21,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 * Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 +### Bug fixes + +* Trim spaces when paginating raw SQL https://github.com/SeaQL/sea-orm/pull/1094 + +### Breaking changes + +* Replaced `usize` with `u64` in `PaginatorTrait` https://github.com/SeaQL/sea-orm/pull/789 + ### House keeping * Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 From 3c19d7c3e4b237d255b670741e400b4d29852d58 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 6 Oct 2022 23:45:09 +0800 Subject: [PATCH 51/71] Exclude test_cfg module from SeaORM (#1077) --- Cargo.toml | 3 ++- src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f119bc75..e7215b0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ actix-rt = { version = "2.2.0" } maplit = { version = "^1" } rust_decimal_macros = { version = "^1" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } -sea-orm = { path = ".", features = ["mock", "debug-print"] } +sea-orm = { path = ".", features = ["mock", "debug-print", "tests-cfg"] } pretty_assertions = { version = "^0.7" } time = { version = "^0.3", features = ["macros"] } @@ -94,3 +94,4 @@ runtime-actix-rustls = ["sqlx/runtime-actix-rustls", "runtime-actix"] runtime-tokio = [] runtime-tokio-native-tls = ["sqlx/runtime-tokio-native-tls", "runtime-tokio"] runtime-tokio-rustls = ["sqlx/runtime-tokio-rustls", "runtime-tokio"] +tests-cfg = [] diff --git a/src/lib.rs b/src/lib.rs index 2162fcc5..9c93b9d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -323,7 +323,7 @@ pub mod query; /// Holds types that defines the schemas of an Entity pub mod schema; #[doc(hidden)] -#[cfg(feature = "macros")] +#[cfg(all(feature = "macros", feature = "tests-cfg"))] pub mod tests_cfg; mod util; From c9fb32e9f15cba8415570629ac3215d5fe1b364c Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 6 Oct 2022 23:45:52 +0800 Subject: [PATCH 52/71] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4121bc8..4f16b774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Documentation grammar fixes https://github.com/SeaQL/sea-orm/pull/1050 * Replace `dotenv` with `dotenvy` in examples https://github.com/SeaQL/sea-orm/pull/1085 +* Exclude test_cfg module from SeaORM https://github.com/SeaQL/sea-orm/pull/1077 ## 0.9.3 - 2022-09-30 From 3a2d5168a88f67b223d834f07dcd241b48d2d432 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 6 Oct 2022 23:48:32 +0800 Subject: [PATCH 53/71] Update CHANGELOG.md --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f16b774..8b8633dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## 0.10.0 - Pending ### New Features -* Support `distinct` & `distinct_on` expression https://github.com/SeaQL/sea-orm/pull/902 -* Generate entity files as a library or module https://github.com/SeaQL/sea-orm/pull/953 -* Generate a new migration template with name prefix of unix timestamp https://github.com/SeaQL/sea-orm/pull/947 +* [sea-orm-cli] Generate entity files as a library or module https://github.com/SeaQL/sea-orm/pull/953 +* [sea-orm-cli] Generate a new migration template with name prefix of unix timestamp https://github.com/SeaQL/sea-orm/pull/947 +* [sea-orm-cli] Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 +* [sea-orm-cli] Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 +* [sea-orm-cli] Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 ### Enhancements +* Support `distinct` & `distinct_on` expression https://github.com/SeaQL/sea-orm/pull/902 * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 -* Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 * Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 -* Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 * `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 -* Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 ### Bug fixes From 29deb0dfd1c3a6b0092727971d5161c1a4a88642 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 6 Oct 2022 23:50:39 +0800 Subject: [PATCH 54/71] Better compile error for entity without primary key (#1020) --- sea-orm-macros/src/derives/entity_model.rs | 24 ++++++++++------------ sea-orm-macros/src/derives/primary_key.rs | 6 ++++++ sea-orm-macros/src/lib.rs | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 61a45a43..193e6e99 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -306,16 +306,15 @@ pub fn expand_derive_entity_model(data: Data, attrs: Vec) -> syn::Res } } - let primary_key = (!primary_keys.is_empty()) - .then(|| { - let auto_increment = auto_increment && primary_keys.len() == 1; - let primary_key_types = if primary_key_types.len() == 1 { - let first = primary_key_types.first(); - quote! { #first } - } else { - quote! { (#primary_key_types) } - }; - quote! { + let primary_key = { + let auto_increment = auto_increment && primary_keys.len() == 1; + let primary_key_types = if primary_key_types.len() == 1 { + let first = primary_key_types.first(); + quote! { #first } + } else { + quote! { (#primary_key_types) } + }; + quote! { #[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] pub enum PrimaryKey { #primary_keys @@ -329,9 +328,8 @@ pub fn expand_derive_entity_model(data: Data, attrs: Vec) -> syn::Res #auto_increment } } - } - }) - .unwrap_or_default(); + } + }; Ok(quote! { #[derive(Copy, Clone, Debug, sea_orm::prelude::EnumIter, sea_orm::prelude::DeriveColumn)] diff --git a/sea-orm-macros/src/derives/primary_key.rs b/sea-orm-macros/src/derives/primary_key.rs index 3a05617b..d5c86c96 100644 --- a/sea-orm-macros/src/derives/primary_key.rs +++ b/sea-orm-macros/src/derives/primary_key.rs @@ -14,6 +14,12 @@ pub fn expand_derive_primary_key(ident: Ident, data: Data) -> syn::Result compile_error!("Entity must have a primary key column. See for details."); + }); + } + let variant: Vec = variants .iter() .map(|Variant { ident, fields, .. }| match fields { diff --git a/sea-orm-macros/src/lib.rs b/sea-orm-macros/src/lib.rs index d3026d23..ef77c1e4 100644 --- a/sea-orm-macros/src/lib.rs +++ b/sea-orm-macros/src/lib.rs @@ -108,6 +108,27 @@ pub fn derive_entity(input: TokenStream) -> TokenStream { /// # /// # impl ActiveModelBehavior for ActiveModel {} /// ``` +/// +/// Entity should always have a primary key. +/// Or, it will result in a compile error. +/// See for details. +/// +/// ```compile_fail +/// use sea_orm::entity::prelude::*; +/// +/// #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +/// #[sea_orm(table_name = "posts")] +/// pub struct Model { +/// pub title: String, +/// #[sea_orm(column_type = "Text")] +/// pub text: String, +/// } +/// +/// # #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +/// # pub enum Relation {} +/// # +/// # impl ActiveModelBehavior for ActiveModel {} +/// ``` #[proc_macro_derive(DeriveEntityModel, attributes(sea_orm))] pub fn derive_entity_model(input: TokenStream) -> TokenStream { let input_ts = input.clone(); From ae198e6d7ccde531e0a3ba9e0d4bd818c03dd85d Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Thu, 6 Oct 2022 23:51:33 +0800 Subject: [PATCH 55/71] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8633dd..671fac1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `fn column()` also handle enum type https://github.com/SeaQL/sea-orm/pull/973 * Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 * `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 +* Better compile error for entity without primary key https://github.com/SeaQL/sea-orm/pull/1020 ### Bug fixes From a44017f679cf66e3b15c8c987afca7334b9641f1 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Fri, 7 Oct 2022 00:15:36 +0800 Subject: [PATCH 56/71] Reorder variants --- src/error.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/error.rs b/src/error.rs index e45b3834..01c4f21f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,6 +18,15 @@ pub enum DbErr { /// TryError source: Box, }, + /// There was a problem with the database connection + #[error("Connection Error: {0}")] + Conn(#[source] RuntimeErr), + /// An operation did not execute successfully + #[error("Execution Error: {0}")] + Exec(#[source] RuntimeErr), + /// An error occurred while performing a query + #[error("Query Error: {0}")] + Query(#[source] RuntimeErr), /// Type error: the specified type cannot be converted from u64. This is not a runtime error. #[error("Type '{0}' cannot be converted from u64")] ConvertFromU64(&'static str), @@ -28,15 +37,6 @@ pub enum DbErr { /// if the record has been correctly updated, otherwise this error will occur #[error("Failed to get primary key from model")] UpdateGetPrimeryKey, - /// There was a problem with the database connection - #[error("Connection Error: {0}")] - Conn(#[source] RuntimeErr), - /// An operation did not execute successfully - #[error("Execution Error: {0}")] - Exec(#[source] RuntimeErr), - /// An error occurred while performing a query - #[error("Query Error: {0}")] - Query(#[source] RuntimeErr), /// The record was not found in the database #[error("RecordNotFound Error: {0}")] RecordNotFound(String), From 9b670f8ca5009cce31d3c43b9b6e29c45b8404eb Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Fri, 7 Oct 2022 00:33:04 +0800 Subject: [PATCH 57/71] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 671fac1b..3524a93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## 0.10.0 - Pending ### New Features +* Better error types (carrying SQLx Error) https://github.com/SeaQL/sea-orm/pull/1002 * [sea-orm-cli] Generate entity files as a library or module https://github.com/SeaQL/sea-orm/pull/953 * [sea-orm-cli] Generate a new migration template with name prefix of unix timestamp https://github.com/SeaQL/sea-orm/pull/947 * [sea-orm-cli] Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 @@ -29,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Breaking changes * Replaced `usize` with `u64` in `PaginatorTrait` https://github.com/SeaQL/sea-orm/pull/789 +* Type signature of `DbErr` changed as a result of https://github.com/SeaQL/sea-orm/pull/1002 ### House keeping From 0f8b4bd1fa5856e961f39bebf954530a814dbd49 Mon Sep 17 00:00:00 2001 From: Matthew Bauer Date: Sun, 9 Oct 2022 02:55:30 -0700 Subject: [PATCH 58/71] Update README.md (#1100) Verbiage change, seems less jank. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eade3e56..36c3bf2e 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. -SeaORM is a community driven project. We welcome you to participate, contribute and together build for Rust's future. +SeaORM is a community driven project. We welcome you to participate, contribute and together help build Rust's future. A big shout out to our contributors: From 880147c596bf724f1c98059c2f56f402aa291c7b Mon Sep 17 00:00:00 2001 From: wdcocq Date: Tue, 11 Oct 2022 17:47:17 +0200 Subject: [PATCH 59/71] Blanket IntoActiveValue implementations so custom db types are supported (#833) * Add blanket implementations of IntoActiveValue for optional values * Add a compile test for DeriveIntoActiveModel --- src/entity/active_model.rs | 42 +++++++++++--------- tests/common/features/custom_active_model.rs | 40 +++++++++++++++++++ tests/common/features/mod.rs | 1 + 3 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 tests/common/features/custom_active_model.rs diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index 0603662c..e63cbea4 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -641,6 +641,30 @@ where fn into_active_value(self) -> ActiveValue; } +impl IntoActiveValue> for Option +where + V: IntoActiveValue + Into + Nullable, +{ + fn into_active_value(self) -> ActiveValue> { + match self { + Some(value) => Set(Some(value)), + None => NotSet, + } + } +} + +impl IntoActiveValue> for Option> +where + V: IntoActiveValue + Into + Nullable, +{ + fn into_active_value(self) -> ActiveValue> { + match self { + Some(value) => Set(value), + None => NotSet, + } + } +} + macro_rules! impl_into_active_value { ($ty: ty) => { impl IntoActiveValue<$ty> for $ty { @@ -648,24 +672,6 @@ macro_rules! impl_into_active_value { Set(self) } } - - impl IntoActiveValue> for Option<$ty> { - fn into_active_value(self) -> ActiveValue> { - match self { - Some(value) => Set(Some(value)), - None => NotSet, - } - } - } - - impl IntoActiveValue> for Option> { - fn into_active_value(self) -> ActiveValue> { - match self { - Some(value) => Set(value), - None => NotSet, - } - } - } }; } diff --git a/tests/common/features/custom_active_model.rs b/tests/common/features/custom_active_model.rs new file mode 100644 index 00000000..6b6e6977 --- /dev/null +++ b/tests/common/features/custom_active_model.rs @@ -0,0 +1,40 @@ +use super::sea_orm_active_enums::*; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue, IntoActiveValue}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[cfg_attr(feature = "sqlx-postgres", sea_orm(schema_name = "public"))] +#[sea_orm(table_name = "custom_active_model")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub weight: Option, + pub amount: Option, + pub category: Option, + pub color: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Clone, Debug, PartialEq, DeriveIntoActiveModel)] +pub struct CustomActiveModel { + pub weight: Option, + pub amount: Option>, + pub category: Option, + pub color: Option>, +} + +impl IntoActiveValue for Category { + fn into_active_value(self) -> ActiveValue { + ActiveValue::set(self) + } +} + +impl IntoActiveValue for Color { + fn into_active_value(self) -> ActiveValue { + ActiveValue::set(self) + } +} diff --git a/tests/common/features/mod.rs b/tests/common/features/mod.rs index 0b26b261..ce95af17 100644 --- a/tests/common/features/mod.rs +++ b/tests/common/features/mod.rs @@ -2,6 +2,7 @@ pub mod active_enum; pub mod active_enum_child; pub mod applog; pub mod byte_primary_key; +pub mod custom_active_model; pub mod insert_default; pub mod json_struct; pub mod json_vec; From 1c2d19a6344e8ec4c6c12ea8c9c4e802048d08c4 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Wed, 12 Oct 2022 00:06:33 +0800 Subject: [PATCH 60/71] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3524a93a..a307bf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Added `acquire_timeout` on `ConnectOptions` https://github.com/SeaQL/sea-orm/pull/897 * `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 * Better compile error for entity without primary key https://github.com/SeaQL/sea-orm/pull/1020 +* Added blanket implementations of `IntoActiveValue` for `Option` values https://github.com/SeaQL/sea-orm/pull/833 ### Bug fixes From 0ca62ba145816c376222a56cfdf92ff60c48aae4 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Thu, 13 Oct 2022 21:24:17 +0800 Subject: [PATCH 61/71] Links to cookbook --- README.md | 1 + src/lib.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 36c3bf2e..17764bca 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Join our Discord server to chat with others in the SeaQL community! + [Getting Started](https://www.sea-ql.org/SeaORM/docs/index) + [Step-by-step Tutorials](https://www.sea-ql.org/sea-orm-tutorial/) ++ [Cookbook](https://www.sea-ql.org/sea-orm-cookbook/) + [Usage Example](https://github.com/SeaQL/sea-orm/tree/master/examples/basic) Integration examples diff --git a/src/lib.rs b/src/lib.rs index 9c93b9d6..540e05c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ //! //! + [Getting Started](https://www.sea-ql.org/SeaORM/docs/index) //! + [Step-by-step Tutorials](https://www.sea-ql.org/sea-orm-tutorial/) +//! + [Cookbook](https://www.sea-ql.org/sea-orm-cookbook/) //! + [Usage Example](https://github.com/SeaQL/sea-orm/tree/master/examples/basic) //! //! Integration examples From 2cdc065b214e1ec51aa450f93e5ea049933d47c5 Mon Sep 17 00:00:00 2001 From: Erick Pacheco Pedraza Date: Sun, 16 Oct 2022 04:45:56 -0500 Subject: [PATCH 62/71] feat: support to Rocket-Okapi (#1071) * feat: support to okapi * fix: fmt checks * chore: rocket-okapi-example: add required schemas * chore: rocket-okapi-example: add dto * chore: rocket-okapi-example: add custom error * chore: rocket-okapi-example: add api controllers * chore: rocket-okapi-example: add notes in Readme * chore: make rocket_okapi optional * refactor: delete rocket example from rocket_example * chore: rocket-okapi-example: add base files for okapi example * chore: rocket-okapi-example: add controllers and dto * chore: rocket-okapi-example: add docs --- examples/rocket_okapi_example/Cargo.toml | 12 ++ examples/rocket_okapi_example/README.md | 12 ++ examples/rocket_okapi_example/Rocket.toml | 11 ++ examples/rocket_okapi_example/api/Cargo.toml | 39 ++++ .../rocket_okapi_example/api/src/error.rs | 117 ++++++++++++ examples/rocket_okapi_example/api/src/lib.rs | 139 +++++++++++++++ .../api/src/okapi_example.rs | 166 ++++++++++++++++++ examples/rocket_okapi_example/api/src/pool.rs | 41 +++++ examples/rocket_okapi_example/core/Cargo.toml | 29 +++ examples/rocket_okapi_example/core/src/lib.rs | 7 + .../rocket_okapi_example/core/src/mutation.rs | 53 ++++++ .../rocket_okapi_example/core/src/query.rs | 26 +++ .../rocket_okapi_example/core/tests/mock.rs | 79 +++++++++ .../core/tests/prepare.rs | 50 ++++++ examples/rocket_okapi_example/dto/Cargo.toml | 20 +++ examples/rocket_okapi_example/dto/src/dto.rs | 12 ++ examples/rocket_okapi_example/dto/src/lib.rs | 1 + .../rocket_okapi_example/entity/Cargo.toml | 21 +++ .../rocket_okapi_example/entity/src/lib.rs | 4 + .../rocket_okapi_example/entity/src/post.rs | 21 +++ .../rocket_okapi_example/migration/Cargo.toml | 22 +++ .../rocket_okapi_example/migration/README.md | 37 ++++ .../rocket_okapi_example/migration/src/lib.rs | 12 ++ .../src/m20220120_000001_create_post_table.rs | 42 +++++ .../migration/src/main.rs | 17 ++ examples/rocket_okapi_example/rapidoc.png | Bin 0 -> 81873 bytes examples/rocket_okapi_example/src/main.rs | 3 + examples/rocket_okapi_example/swagger.png | Bin 0 -> 87378 bytes sea-orm-rocket/lib/Cargo.toml | 5 + sea-orm-rocket/lib/src/database.rs | 16 ++ 30 files changed, 1014 insertions(+) create mode 100644 examples/rocket_okapi_example/Cargo.toml create mode 100644 examples/rocket_okapi_example/README.md create mode 100644 examples/rocket_okapi_example/Rocket.toml create mode 100644 examples/rocket_okapi_example/api/Cargo.toml create mode 100644 examples/rocket_okapi_example/api/src/error.rs create mode 100644 examples/rocket_okapi_example/api/src/lib.rs create mode 100644 examples/rocket_okapi_example/api/src/okapi_example.rs create mode 100644 examples/rocket_okapi_example/api/src/pool.rs create mode 100644 examples/rocket_okapi_example/core/Cargo.toml create mode 100644 examples/rocket_okapi_example/core/src/lib.rs create mode 100644 examples/rocket_okapi_example/core/src/mutation.rs create mode 100644 examples/rocket_okapi_example/core/src/query.rs create mode 100644 examples/rocket_okapi_example/core/tests/mock.rs create mode 100644 examples/rocket_okapi_example/core/tests/prepare.rs create mode 100644 examples/rocket_okapi_example/dto/Cargo.toml create mode 100644 examples/rocket_okapi_example/dto/src/dto.rs create mode 100644 examples/rocket_okapi_example/dto/src/lib.rs create mode 100644 examples/rocket_okapi_example/entity/Cargo.toml create mode 100644 examples/rocket_okapi_example/entity/src/lib.rs create mode 100644 examples/rocket_okapi_example/entity/src/post.rs create mode 100644 examples/rocket_okapi_example/migration/Cargo.toml create mode 100644 examples/rocket_okapi_example/migration/README.md create mode 100644 examples/rocket_okapi_example/migration/src/lib.rs create mode 100644 examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs create mode 100644 examples/rocket_okapi_example/migration/src/main.rs create mode 100644 examples/rocket_okapi_example/rapidoc.png create mode 100644 examples/rocket_okapi_example/src/main.rs create mode 100644 examples/rocket_okapi_example/swagger.png diff --git a/examples/rocket_okapi_example/Cargo.toml b/examples/rocket_okapi_example/Cargo.toml new file mode 100644 index 00000000..87dfde6f --- /dev/null +++ b/examples/rocket_okapi_example/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sea-orm-rocket-okapi-example" +version = "0.1.0" +authors = ["Sam Samai ", "Erick Pacheco "] +edition = "2021" +publish = false + +[dependencies] +async-stream = { version = "^0.3" } +async-trait = { version = "0.1" } +rocket-example-core = { path = "../core" } +futures = { version = "^0.3" } +futures-util = { version = "^0.3" } +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } +rocket_dyn_templates = { version = "0.1.0-rc.1", features = [ + "tera", +] } +serde_json = { version = "^1" } +entity = { path = "../entity" } +migration = { path = "../migration" } +tokio = "1.20.0" +serde = "1.0" +dto = { path = "../dto" } + +[dependencies.sea-orm-rocket] +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the git line +features = ["rocket_okapi"] #enables rocket_okapi so to have open api features enabled +# git = "https://github.com/SeaQL/sea-orm" + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" +features = ["swagger", "rapidoc","rocket_db_pools"] + +[dependencies.rocket_cors] +git = "https://github.com/lawliet89/rocket_cors.git" +rev = "54fae070" +default-features = false \ No newline at end of file diff --git a/examples/rocket_okapi_example/api/src/error.rs b/examples/rocket_okapi_example/api/src/error.rs new file mode 100644 index 00000000..88a27472 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/error.rs @@ -0,0 +1,117 @@ +use rocket::{ + http::{ContentType, Status}, + request::Request, + response::{self, Responder, Response}, +}; +use rocket_okapi::okapi::openapi3::Responses; +use rocket_okapi::okapi::schemars::{self, Map}; +use rocket_okapi::{gen::OpenApiGenerator, response::OpenApiResponderInner, OpenApiError}; + +/// Error messages returned to user +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +pub struct Error { + /// The title of the error message + pub err: String, + /// The description of the error + pub msg: Option, + // HTTP Status Code returned + #[serde(skip)] + pub http_status_code: u16, +} + +impl OpenApiResponderInner for Error { + fn responses(_generator: &mut OpenApiGenerator) -> Result { + use rocket_okapi::okapi::openapi3::{RefOr, Response as OpenApiReponse}; + + let mut responses = Map::new(); + responses.insert( + "400".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [400 Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400)\n\ + The request given is wrongly formatted or data asked could not be fulfilled. \ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "404".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [404 Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404)\n\ + This response is given when you request a page that does not exists.\ + " + .to_string(), + ..Default::default() + }), + ); + responses.insert( + "422".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [422 Unprocessable Entity](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422)\n\ + This response is given when you request body is not correctly formatted. \ + ".to_string(), + ..Default::default() + }), + ); + responses.insert( + "500".to_string(), + RefOr::Object(OpenApiReponse { + description: "\ + # [500 Internal Server Error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500)\n\ + This response is given when something wend wrong on the server. \ + ".to_string(), + ..Default::default() + }), + ); + Ok(Responses { + responses, + ..Default::default() + }) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "Error `{}`: {}", + self.err, + self.msg.as_deref().unwrap_or("") + ) + } +} + +impl std::error::Error for Error {} + +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { + // Convert object to json + let body = serde_json::to_string(&self).unwrap(); + Response::build() + .sized_body(body.len(), std::io::Cursor::new(body)) + .header(ContentType::JSON) + .status(Status::new(self.http_status_code)) + .ok() + } +} + +impl From> for Error { + fn from(err: rocket::serde::json::Error) -> Self { + use rocket::serde::json::Error::*; + match err { + Io(io_error) => Error { + err: "IO Error".to_owned(), + msg: Some(io_error.to_string()), + http_status_code: 422, + }, + Parse(_raw_data, parse_error) => Error { + err: "Parse Error".to_owned(), + msg: Some(parse_error.to_string()), + http_status_code: 422, + }, + } + } +} diff --git a/examples/rocket_okapi_example/api/src/lib.rs b/examples/rocket_okapi_example/api/src/lib.rs new file mode 100644 index 00000000..03cf66b2 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/lib.rs @@ -0,0 +1,139 @@ +#[macro_use] +extern crate rocket; + +use rocket::fairing::{self, AdHoc}; +use rocket::{Build, Rocket}; + +use migration::MigratorTrait; +use sea_orm_rocket::Database; + +use rocket_okapi::mount_endpoints_and_merged_docs; +use rocket_okapi::okapi::openapi3::OpenApi; +use rocket_okapi::rapidoc::{make_rapidoc, GeneralConfig, HideShowConfig, RapiDocConfig}; +use rocket_okapi::settings::UrlObject; +use rocket_okapi::swagger_ui::{make_swagger_ui, SwaggerUIConfig}; + +use rocket::http::Method; +use rocket_cors::{AllowedHeaders, AllowedOrigins, Cors}; + +mod pool; +use pool::Db; +mod error; +mod okapi_example; + +pub use entity::post; +pub use entity::post::Entity as Post; + +async fn run_migrations(rocket: Rocket) -> fairing::Result { + let conn = &Db::fetch(&rocket).unwrap().conn; + let _ = migration::Migrator::up(conn, None).await; + Ok(rocket) +} + +#[tokio::main] +async fn start() -> Result<(), rocket::Error> { + let mut building_rocket = rocket::build() + .attach(Db::init()) + .attach(AdHoc::try_on_ignite("Migrations", run_migrations)) + .mount( + "/swagger-ui/", + make_swagger_ui(&SwaggerUIConfig { + url: "../v1/openapi.json".to_owned(), + ..Default::default() + }), + ) + .mount( + "/rapidoc/", + make_rapidoc(&RapiDocConfig { + title: Some("Rocket/SeaOrm - RapiDoc documentation | RapiDoc".to_owned()), + general: GeneralConfig { + spec_urls: vec![UrlObject::new("General", "../v1/openapi.json")], + ..Default::default() + }, + hide_show: HideShowConfig { + allow_spec_url_load: false, + allow_spec_file_load: false, + ..Default::default() + }, + ..Default::default() + }), + ) + .attach(cors()); + + let openapi_settings = rocket_okapi::settings::OpenApiSettings::default(); + let custom_route_spec = (vec![], custom_openapi_spec()); + mount_endpoints_and_merged_docs! { + building_rocket, "/v1".to_owned(), openapi_settings, + "/additional" => custom_route_spec, + "/okapi-example" => okapi_example::get_routes_and_docs(&openapi_settings), + }; + + building_rocket.launch().await.map(|_| ()) +} + +fn cors() -> Cors { + let allowed_origins = + AllowedOrigins::some_exact(&["http://localhost:8000", "http://127.0.0.1:8000"]); + + let cors = rocket_cors::CorsOptions { + allowed_origins, + allowed_methods: vec![Method::Get, Method::Post, Method::Delete] + .into_iter() + .map(From::from) + .collect(), + allowed_headers: AllowedHeaders::all(), + allow_credentials: true, + ..Default::default() + } + .to_cors() + .unwrap(); + cors +} + +fn custom_openapi_spec() -> OpenApi { + use rocket_okapi::okapi::openapi3::*; + OpenApi { + openapi: OpenApi::default_version(), + info: Info { + title: "SeaOrm-Rocket-Okapi Example".to_owned(), + description: Some("API Docs for Rocket/SeaOrm example".to_owned()), + terms_of_service: Some("https://github.com/SeaQL/sea-orm#license".to_owned()), + contact: Some(Contact { + name: Some("SeaOrm".to_owned()), + url: Some("https://github.com/SeaQL/sea-orm".to_owned()), + email: None, + ..Default::default() + }), + license: Some(License { + name: "MIT".to_owned(), + url: Some("https://github.com/SeaQL/sea-orm/blob/master/LICENSE-MIT".to_owned()), + ..Default::default() + }), + version: env!("CARGO_PKG_VERSION").to_owned(), + ..Default::default() + }, + servers: vec![ + Server { + url: "http://127.0.0.1:8000/v1".to_owned(), + description: Some("Localhost".to_owned()), + ..Default::default() + }, + Server { + url: "https://production-server.com/".to_owned(), + description: Some("Remote development server".to_owned()), + ..Default::default() + }, + ], + ..Default::default() + } +} + +pub fn main() { + let result = start(); + + println!("Rocket: deorbit."); + + if let Some(err) = result.err() { + println!("Error: {}", err); + } +} diff --git a/examples/rocket_okapi_example/api/src/okapi_example.rs b/examples/rocket_okapi_example/api/src/okapi_example.rs new file mode 100644 index 00000000..69d15d18 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/okapi_example.rs @@ -0,0 +1,166 @@ +use dto::dto; +use rocket::serde::json::Json; +use rocket_example_core::{Mutation, Query}; + +use sea_orm_rocket::Connection; + +use rocket_okapi::okapi::openapi3::OpenApi; + +use crate::error; +use crate::pool; +use pool::Db; + +pub use entity::post; +pub use entity::post::Entity as Post; + +use rocket_okapi::settings::OpenApiSettings; + +use rocket_okapi::{openapi, openapi_get_routes_spec}; + +const DEFAULT_POSTS_PER_PAGE: u64 = 5; + +pub fn get_routes_and_docs(settings: &OpenApiSettings) -> (Vec, OpenApi) { + openapi_get_routes_spec![settings: create, update, list, get_by_id, delete, destroy] +} + +pub type R = std::result::Result, error::Error>; +pub type DataResult<'a, T> = + std::result::Result, rocket::serde::json::Error<'a>>; + +/// # Add a new post +#[openapi(tag = "POST")] +#[post("/", data = "")] +async fn create( + conn: Connection<'_, Db>, + post_data: DataResult<'_, post::Model>, +) -> R> { + let db = conn.into_inner(); + let form = post_data?.into_inner(); + let cmd = Mutation::create_post(db, form); + match cmd.await { + Ok(_) => Ok(Json(Some("Post successfully added.".to_string()))), + Err(e) => { + let m = error::Error { + err: "Could not insert post".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} + +/// # Update a post +#[openapi(tag = "POST")] +#[post("/", data = "")] +async fn update( + conn: Connection<'_, Db>, + id: i32, + post_data: DataResult<'_, post::Model>, +) -> R> { + let db = conn.into_inner(); + + let form = post_data?.into_inner(); + + let cmd = Mutation::update_post_by_id(db, id, form); + match cmd.await { + Ok(_) => Ok(Json(Some("Post successfully updated.".to_string()))), + Err(e) => { + let m = error::Error { + err: "Could not update post".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} + +/// # Get post list +#[openapi(tag = "POST")] +#[get("/?&")] +async fn list( + conn: Connection<'_, Db>, + page: Option, + posts_per_page: Option, +) -> R { + let db = conn.into_inner(); + + // Set page number and items per page + let page = page.unwrap_or(1); + let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE); + if page == 0 { + let m = error::Error { + err: "error getting posts".to_string(), + msg: Some("'page' param cannot be zero".to_string()), + http_status_code: 400, + }; + return Err(m); + } + + let (posts, num_pages) = Query::find_posts_in_page(db, page, posts_per_page) + .await + .expect("Cannot find posts in page"); + + Ok(Json(dto::PostsDto { + page, + posts_per_page, + num_pages, + posts, + })) +} + +/// # get post by Id +#[openapi(tag = "POST")] +#[get("/")] +async fn get_by_id(conn: Connection<'_, Db>, id: i32) -> R> { + let db = conn.into_inner(); + + let post: Option = Query::find_post_by_id(db, id) + .await + .expect("could not find post"); + Ok(Json(post)) +} + +/// # delete post by Id +#[openapi(tag = "POST")] +#[delete("/")] +async fn delete(conn: Connection<'_, Db>, id: i32) -> R> { + let db = conn.into_inner(); + + let cmd = Mutation::delete_post(db, id); + match cmd.await { + Ok(_) => Ok(Json(Some("Post successfully deleted.".to_string()))), + Err(e) => { + let m = error::Error { + err: "Error deleting post".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} + +/// # delete all posts +#[openapi(tag = "POST")] +#[delete("/")] +async fn destroy(conn: Connection<'_, Db>) -> R> { + let db = conn.into_inner(); + + let cmd = Mutation::delete_all_posts(db); + + match cmd.await { + Ok(_) => Ok(Json(Some( + "All Posts were successfully deleted.".to_string(), + ))), + Err(e) => { + let m = error::Error { + err: "Error deleting all posts at once".to_string(), + msg: Some(e.to_string()), + http_status_code: 400, + }; + Err(m) + } + } +} diff --git a/examples/rocket_okapi_example/api/src/pool.rs b/examples/rocket_okapi_example/api/src/pool.rs new file mode 100644 index 00000000..b1c05677 --- /dev/null +++ b/examples/rocket_okapi_example/api/src/pool.rs @@ -0,0 +1,41 @@ +use rocket_example_core::sea_orm; + +use async_trait::async_trait; +use sea_orm::ConnectOptions; +use sea_orm_rocket::{rocket::figment::Figment, Config, Database}; +use std::time::Duration; + +#[derive(Database, Debug)] +#[database("sea_orm")] +pub struct Db(SeaOrmPool); + +#[derive(Debug, Clone)] +pub struct SeaOrmPool { + pub conn: sea_orm::DatabaseConnection, +} + +#[async_trait] +impl sea_orm_rocket::Pool for SeaOrmPool { + type Error = sea_orm::DbErr; + + type Connection = sea_orm::DatabaseConnection; + + async fn init(figment: &Figment) -> Result { + let config = figment.extract::().unwrap(); + let mut options: ConnectOptions = config.url.into(); + options + .max_connections(config.max_connections as u32) + .min_connections(config.min_connections.unwrap_or_default()) + .connect_timeout(Duration::from_secs(config.connect_timeout)); + if let Some(idle_timeout) = config.idle_timeout { + options.idle_timeout(Duration::from_secs(idle_timeout)); + } + let conn = sea_orm::Database::connect(options).await?; + + Ok(SeaOrmPool { conn }) + } + + fn borrow(&self) -> &Self::Connection { + &self.conn + } +} diff --git a/examples/rocket_okapi_example/core/Cargo.toml b/examples/rocket_okapi_example/core/Cargo.toml new file mode 100644 index 00000000..a57a5560 --- /dev/null +++ b/examples/rocket_okapi_example/core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "rocket-example-core" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +entity = { path = "../entity" } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version +features = [ + "runtime-tokio-native-tls", + "sqlx-postgres", + # "sqlx-mysql", + # "sqlx-sqlite", +] + +[dev-dependencies] +tokio = "1.20.0" + +[features] +mock = ["sea-orm/mock"] + +[[test]] +name = "mock" +required-features = ["mock"] diff --git a/examples/rocket_okapi_example/core/src/lib.rs b/examples/rocket_okapi_example/core/src/lib.rs new file mode 100644 index 00000000..4a80f239 --- /dev/null +++ b/examples/rocket_okapi_example/core/src/lib.rs @@ -0,0 +1,7 @@ +mod mutation; +mod query; + +pub use mutation::*; +pub use query::*; + +pub use sea_orm; diff --git a/examples/rocket_okapi_example/core/src/mutation.rs b/examples/rocket_okapi_example/core/src/mutation.rs new file mode 100644 index 00000000..dd6891d4 --- /dev/null +++ b/examples/rocket_okapi_example/core/src/mutation.rs @@ -0,0 +1,53 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Mutation; + +impl Mutation { + pub async fn create_post( + db: &DbConn, + form_data: post::Model, + ) -> Result { + post::ActiveModel { + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + ..Default::default() + } + .save(db) + .await + } + + pub async fn update_post_by_id( + db: &DbConn, + id: i32, + form_data: post::Model, + ) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post::ActiveModel { + id: post.id, + title: Set(form_data.title.to_owned()), + text: Set(form_data.text.to_owned()), + } + .update(db) + .await + } + + pub async fn delete_post(db: &DbConn, id: i32) -> Result { + let post: post::ActiveModel = Post::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::Custom("Cannot find post.".to_owned())) + .map(Into::into)?; + + post.delete(db).await + } + + pub async fn delete_all_posts(db: &DbConn) -> Result { + Post::delete_many().exec(db).await + } +} diff --git a/examples/rocket_okapi_example/core/src/query.rs b/examples/rocket_okapi_example/core/src/query.rs new file mode 100644 index 00000000..e8d2668f --- /dev/null +++ b/examples/rocket_okapi_example/core/src/query.rs @@ -0,0 +1,26 @@ +use ::entity::{post, post::Entity as Post}; +use sea_orm::*; + +pub struct Query; + +impl Query { + pub async fn find_post_by_id(db: &DbConn, id: i32) -> Result, DbErr> { + Post::find_by_id(id).one(db).await + } + + /// If ok, returns (post models, num pages). + pub async fn find_posts_in_page( + db: &DbConn, + page: u64, + posts_per_page: u64, + ) -> Result<(Vec, u64), DbErr> { + // Setup paginator + let paginator = Post::find() + .order_by_asc(post::Column::Id) + .paginate(db, posts_per_page); + let num_pages = paginator.num_pages().await?; + + // Fetch paginated posts + paginator.fetch_page(page - 1).await.map(|p| (p, num_pages)) + } +} diff --git a/examples/rocket_okapi_example/core/tests/mock.rs b/examples/rocket_okapi_example/core/tests/mock.rs new file mode 100644 index 00000000..84b187e5 --- /dev/null +++ b/examples/rocket_okapi_example/core/tests/mock.rs @@ -0,0 +1,79 @@ +mod prepare; + +use entity::post; +use prepare::prepare_mock_db; +use rocket_example_core::{Mutation, Query}; + +#[tokio::test] +async fn main() { + let db = &prepare_mock_db(); + + { + let post = Query::find_post_by_id(db, 1).await.unwrap().unwrap(); + + assert_eq!(post.id, 1); + } + + { + let post = Query::find_post_by_id(db, 5).await.unwrap().unwrap(); + + assert_eq!(post.id, 5); + } + + { + let post = Mutation::create_post( + db, + post::Model { + id: 0, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(6), + title: sea_orm::ActiveValue::Unchanged("Title D".to_owned()), + text: sea_orm::ActiveValue::Unchanged("Text D".to_owned()) + } + ); + } + + { + let post = Mutation::update_post_by_id( + db, + 1, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }, + ) + .await + .unwrap(); + + assert_eq!( + post, + post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + } + ); + } + + { + let result = Mutation::delete_post(db, 5).await.unwrap(); + + assert_eq!(result.rows_affected, 1); + } + + { + let result = Mutation::delete_all_posts(db).await.unwrap(); + + assert_eq!(result.rows_affected, 5); + } +} diff --git a/examples/rocket_okapi_example/core/tests/prepare.rs b/examples/rocket_okapi_example/core/tests/prepare.rs new file mode 100644 index 00000000..45180493 --- /dev/null +++ b/examples/rocket_okapi_example/core/tests/prepare.rs @@ -0,0 +1,50 @@ +use ::entity::post; +use sea_orm::*; + +#[cfg(feature = "mock")] +pub fn prepare_mock_db() -> DatabaseConnection { + MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![ + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + vec![post::Model { + id: 6, + title: "Title D".to_owned(), + text: "Text D".to_owned(), + }], + vec![post::Model { + id: 1, + title: "Title A".to_owned(), + text: "Text A".to_owned(), + }], + vec![post::Model { + id: 1, + title: "New Title A".to_owned(), + text: "New Text A".to_owned(), + }], + vec![post::Model { + id: 5, + title: "Title C".to_owned(), + text: "Text C".to_owned(), + }], + ]) + .append_exec_results(vec![ + MockExecResult { + last_insert_id: 6, + rows_affected: 1, + }, + MockExecResult { + last_insert_id: 6, + rows_affected: 5, + }, + ]) + .into_connection() +} diff --git a/examples/rocket_okapi_example/dto/Cargo.toml b/examples/rocket_okapi_example/dto/Cargo.toml new file mode 100644 index 00000000..a0f208db --- /dev/null +++ b/examples/rocket_okapi_example/dto/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dto" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "dto" +path = "src/lib.rs" + +[dependencies] +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } + +[dependencies.entity] +path = "../entity" + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" \ No newline at end of file diff --git a/examples/rocket_okapi_example/dto/src/dto.rs b/examples/rocket_okapi_example/dto/src/dto.rs new file mode 100644 index 00000000..976b2cf0 --- /dev/null +++ b/examples/rocket_okapi_example/dto/src/dto.rs @@ -0,0 +1,12 @@ +use entity::*; +use rocket::serde::{Deserialize, Serialize}; +use rocket_okapi::okapi::schemars::{self, JsonSchema}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(crate = "rocket::serde")] +pub struct PostsDto { + pub page: u64, + pub posts_per_page: u64, + pub num_pages: u64, + pub posts: Vec, +} diff --git a/examples/rocket_okapi_example/dto/src/lib.rs b/examples/rocket_okapi_example/dto/src/lib.rs new file mode 100644 index 00000000..a07dce5c --- /dev/null +++ b/examples/rocket_okapi_example/dto/src/lib.rs @@ -0,0 +1 @@ +pub mod dto; diff --git a/examples/rocket_okapi_example/entity/Cargo.toml b/examples/rocket_okapi_example/entity/Cargo.toml new file mode 100644 index 00000000..c1cf045d --- /dev/null +++ b/examples/rocket_okapi_example/entity/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "entity" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "entity" +path = "src/lib.rs" + +[dependencies] +rocket = { version = "0.5.0-rc.1", features = [ + "json", +] } + +[dependencies.sea-orm] +path = "../../../" # remove this line in your own project +version = "^0.10.0" # sea-orm version + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" diff --git a/examples/rocket_okapi_example/entity/src/lib.rs b/examples/rocket_okapi_example/entity/src/lib.rs new file mode 100644 index 00000000..06480a10 --- /dev/null +++ b/examples/rocket_okapi_example/entity/src/lib.rs @@ -0,0 +1,4 @@ +#[macro_use] +extern crate rocket; + +pub mod post; diff --git a/examples/rocket_okapi_example/entity/src/post.rs b/examples/rocket_okapi_example/entity/src/post.rs new file mode 100644 index 00000000..a5797f48 --- /dev/null +++ b/examples/rocket_okapi_example/entity/src/post.rs @@ -0,0 +1,21 @@ +use rocket::serde::{Deserialize, Serialize}; +use rocket_okapi::okapi::schemars::{self, JsonSchema}; +use sea_orm::entity::prelude::*; + +#[derive( + Clone, Debug, PartialEq, Eq, DeriveEntityModel, Deserialize, Serialize, FromForm, JsonSchema, +)] +#[serde(crate = "rocket::serde")] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + #[sea_orm(column_type = "Text")] + pub text: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/rocket_okapi_example/migration/Cargo.toml b/examples/rocket_okapi_example/migration/Cargo.toml new file mode 100644 index 00000000..b8251d20 --- /dev/null +++ b/examples/rocket_okapi_example/migration/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "migration" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "migration" +path = "src/lib.rs" + +[dependencies] +rocket = { version = "0.5.0-rc.1" } +async-std = { version = "^1", features = ["attributes", "tokio1"] } + +[dependencies.sea-orm-migration] +path = "../../../sea-orm-migration" # remove this line in your own project +version = "^0.10.0" # sea-orm-migration version +features = [ + # Enable following runtime and db backend features if you want to run migration via CLI + # "runtime-tokio-native-tls", + # "sqlx-postgres", +] diff --git a/examples/rocket_okapi_example/migration/README.md b/examples/rocket_okapi_example/migration/README.md new file mode 100644 index 00000000..963caaeb --- /dev/null +++ b/examples/rocket_okapi_example/migration/README.md @@ -0,0 +1,37 @@ +# Running Migrator CLI + +- Apply all pending migrations + ```sh + cargo run + ``` + ```sh + cargo run -- up + ``` +- Apply first 10 pending migrations + ```sh + cargo run -- up -n 10 + ``` +- Rollback last applied migrations + ```sh + cargo run -- down + ``` +- Rollback last 10 applied migrations + ```sh + cargo run -- down -n 10 + ``` +- Drop all tables from the database, then reapply all migrations + ```sh + cargo run -- fresh + ``` +- Rollback all applied migrations, then reapply all migrations + ```sh + cargo run -- refresh + ``` +- Rollback all applied migrations + ```sh + cargo run -- reset + ``` +- Check the status of all migrations + ```sh + cargo run -- status + ``` diff --git a/examples/rocket_okapi_example/migration/src/lib.rs b/examples/rocket_okapi_example/migration/src/lib.rs new file mode 100644 index 00000000..af8d9b2a --- /dev/null +++ b/examples/rocket_okapi_example/migration/src/lib.rs @@ -0,0 +1,12 @@ +pub use sea_orm_migration::prelude::*; + +mod m20220120_000001_create_post_table; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![Box::new(m20220120_000001_create_post_table::Migration)] + } +} diff --git a/examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs b/examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs new file mode 100644 index 00000000..a2fa0219 --- /dev/null +++ b/examples/rocket_okapi_example/migration/src/m20220120_000001_create_post_table.rs @@ -0,0 +1,42 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Posts::Table) + .if_not_exists() + .col( + ColumnDef::new(Posts::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Posts::Title).string().not_null()) + .col(ColumnDef::new(Posts::Text).string().not_null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Posts::Table).to_owned()) + .await + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +enum Posts { + Table, + Id, + Title, + Text, +} diff --git a/examples/rocket_okapi_example/migration/src/main.rs b/examples/rocket_okapi_example/migration/src/main.rs new file mode 100644 index 00000000..4626e82f --- /dev/null +++ b/examples/rocket_okapi_example/migration/src/main.rs @@ -0,0 +1,17 @@ +use sea_orm_migration::prelude::*; + +#[async_std::main] +async fn main() { + // Setting `DATABASE_URL` environment variable + let key = "DATABASE_URL"; + if std::env::var(key).is_err() { + // Getting the database URL from Rocket.toml if it's not set + let figment = rocket::Config::figment(); + let database_url: String = figment + .extract_inner("databases.sea_orm.url") + .expect("Cannot find Database URL in Rocket.toml"); + std::env::set_var(key, database_url); + } + + cli::run_cli(migration::Migrator).await; +} diff --git a/examples/rocket_okapi_example/rapidoc.png b/examples/rocket_okapi_example/rapidoc.png new file mode 100644 index 0000000000000000000000000000000000000000..f3f6c55c6860e8340ade3d0bcd57e38c85de5916 GIT binary patch literal 81873 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%0A=Vs$0XM;=pWo-iuYX``Z;{R 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 z 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 KBwf7o_&)$Ci-A7? literal 0 HcmV?d00001 diff --git a/sea-orm-rocket/lib/Cargo.toml b/sea-orm-rocket/lib/Cargo.toml index c2be9508..48797a65 100644 --- a/sea-orm-rocket/lib/Cargo.toml +++ b/sea-orm-rocket/lib/Cargo.toml @@ -24,3 +24,8 @@ version = "0.5.0-rc.1" version = "0.5.0-rc.1" default-features = false features = ["json"] + +[dependencies.rocket_okapi] +version = "0.8.0-rc.2" +default-features = false +optional = true diff --git a/sea-orm-rocket/lib/src/database.rs b/sea-orm-rocket/lib/src/database.rs index 8826b5f9..61ddf16f 100644 --- a/sea-orm-rocket/lib/src/database.rs +++ b/sea-orm-rocket/lib/src/database.rs @@ -9,6 +9,11 @@ use rocket::{error, info_, Build, Ignite, Phase, Rocket, Sentinel}; use rocket::figment::providers::Serialized; use rocket::yansi::Paint; +#[cfg(feature = "rocket_okapi")] +use rocket_okapi::gen::OpenApiGenerator; +#[cfg(feature = "rocket_okapi")] +use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; + use crate::Pool; /// Derivable trait which ties a database [`Pool`] with a configuration name. @@ -205,6 +210,17 @@ impl<'a, D: Database> Connection<'a, D> { } } +#[cfg(feature = "rocket_okapi")] +impl<'r, D: Database> OpenApiFromRequest<'r> for Connection<'r, D> { + fn from_request_input( + _gen: &mut OpenApiGenerator, + _name: String, + _required: bool, + ) -> rocket_okapi::Result { + Ok(RequestHeaderInput::None) + } +} + #[rocket::async_trait] impl Fairing for Initializer { fn info(&self) -> Info { From 1aa84589dce64ac1fcd619f90154a39ce55df561 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 16 Oct 2022 17:18:58 +0800 Subject: [PATCH 63/71] Refactor --- sea-orm-rocket/lib/src/database.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sea-orm-rocket/lib/src/database.rs b/sea-orm-rocket/lib/src/database.rs index 61ddf16f..43792672 100644 --- a/sea-orm-rocket/lib/src/database.rs +++ b/sea-orm-rocket/lib/src/database.rs @@ -10,9 +10,10 @@ use rocket::figment::providers::Serialized; use rocket::yansi::Paint; #[cfg(feature = "rocket_okapi")] -use rocket_okapi::gen::OpenApiGenerator; -#[cfg(feature = "rocket_okapi")] -use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; +use rocket_okapi::{ + gen::OpenApiGenerator, + request::{OpenApiFromRequest, RequestHeaderInput}, +}; use crate::Pool; From ae0231d4a5addfacf501080021d433e2699106b8 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 16 Oct 2022 17:25:58 +0800 Subject: [PATCH 64/71] CI --- .github/workflows/rust.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 49d801ec..bd25127b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -394,6 +394,10 @@ jobs: toolchain: stable override: true + - name: check rustfmt + run: | + cargo fmt --manifest-path ${{ matrix.path }} --all -- --check + - uses: actions-rs/cargo@v1 with: command: build @@ -415,12 +419,6 @@ jobs: --manifest-path ${{ matrix.path }} --features mock - - name: check rustfmt - run: | - rustup override set nightly - rustup component add rustfmt - cargo +nightly fmt --manifest-path ${{ matrix.path }} --all -- --check - issues-matrix: name: Issues Matrix needs: init From 5b1f796fa26080866d9ed77ebc8bd696c2aa8952 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 16 Oct 2022 17:28:11 +0800 Subject: [PATCH 65/71] Changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a307bf7c..f0ecc176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Replace `dotenv` with `dotenvy` in examples https://github.com/SeaQL/sea-orm/pull/1085 * Exclude test_cfg module from SeaORM https://github.com/SeaQL/sea-orm/pull/1077 +### Integration + +* Support `rocket_okapi` https://github.com/SeaQL/sea-orm/pull/1071 + ## 0.9.3 - 2022-09-30 ### Enhancements From 8d7aff12baaeb98d40be4d879fb73dc5b086e8c3 Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 16 Oct 2022 17:33:59 +0800 Subject: [PATCH 66/71] sea-orm-rocket 0.5.1 --- examples/rocket_example/api/Cargo.toml | 4 ++-- examples/rocket_okapi_example/api/Cargo.toml | 6 +++--- sea-orm-rocket/lib/Cargo.toml | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/rocket_example/api/Cargo.toml b/examples/rocket_example/api/Cargo.toml index 1a88308f..2756af20 100644 --- a/examples/rocket_example/api/Cargo.toml +++ b/examples/rocket_example/api/Cargo.toml @@ -23,5 +23,5 @@ migration = { path = "../migration" } tokio = "1.20.0" [dependencies.sea-orm-rocket] -path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the git line -# git = "https://github.com/SeaQL/sea-orm" +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and uncomment the following line +# version = "0.5.0" diff --git a/examples/rocket_okapi_example/api/Cargo.toml b/examples/rocket_okapi_example/api/Cargo.toml index 3476fb99..00ecdbaf 100644 --- a/examples/rocket_okapi_example/api/Cargo.toml +++ b/examples/rocket_okapi_example/api/Cargo.toml @@ -25,9 +25,9 @@ serde = "1.0" dto = { path = "../dto" } [dependencies.sea-orm-rocket] -path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the git line -features = ["rocket_okapi"] #enables rocket_okapi so to have open api features enabled -# git = "https://github.com/SeaQL/sea-orm" +path = "../../../sea-orm-rocket/lib" # remove this line in your own project and use the version line +features = ["rocket_okapi"] # enables rocket_okapi so to have open api features enabled +# version = "0.5.1" [dependencies.rocket_okapi] version = "0.8.0-rc.2" diff --git a/sea-orm-rocket/lib/Cargo.toml b/sea-orm-rocket/lib/Cargo.toml index 48797a65..32402355 100644 --- a/sea-orm-rocket/lib/Cargo.toml +++ b/sea-orm-rocket/lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sea-orm-rocket" -version = "0.5.0" +version = "0.5.1" authors = ["Sergio Benitez ", "Jeb Rosen "] description = "SeaORM Rocket support crate" repository = "https://github.com/SeaQL/sea-orm" @@ -20,12 +20,12 @@ default-features = false path = "../codegen" version = "0.5.0-rc.1" -[dev-dependencies.rocket] -version = "0.5.0-rc.1" -default-features = false -features = ["json"] - [dependencies.rocket_okapi] version = "0.8.0-rc.2" default-features = false -optional = true +optional = true + +[dev-dependencies.rocket] +version = "0.5.0-rc.1" +default-features = false +features = ["json"] \ No newline at end of file From 18215871ba713cdc8a23f2381d21d5ab33e8129b Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 16 Oct 2022 18:10:52 +0800 Subject: [PATCH 67/71] [cli] make `dotenvy` and `async-std` optional dependencies (#1116) --- sea-orm-cli/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sea-orm-cli/Cargo.toml b/sea-orm-cli/Cargo.toml index 3885e5a1..710f84be 100644 --- a/sea-orm-cli/Cargo.toml +++ b/sea-orm-cli/Cargo.toml @@ -32,8 +32,8 @@ required-features = ["codegen"] [dependencies] clap = { version = "^3.2", features = ["env", "derive"] } -dotenvy = { version = "^0.15" } -async-std = { version = "^1.9", features = [ "attributes", "tokio1" ] } +dotenvy = { version = "^0.15", optional = true } +async-std = { version = "^1.9", features = [ "attributes", "tokio1" ], optional = true } sea-orm-codegen = { version = "^0.10.0", path = "../sea-orm-codegen", optional = true } sea-schema = { version = "^0.9.3" } sqlx = { version = "^0.6", default-features = false, features = [ "mysql", "postgres" ], optional = true } @@ -47,7 +47,7 @@ regex = "1" smol = "1.2.5" [features] -default = [ "codegen", "runtime-async-std-native-tls" ] +default = [ "codegen", "runtime-async-std-native-tls", "dotenvy", "async-std" ] codegen = [ "sea-schema/sqlx-all", "sea-orm-codegen" ] runtime-actix-native-tls = [ "sqlx/runtime-actix-native-tls", "sea-schema/runtime-actix-native-tls" ] runtime-async-std-native-tls = [ "sqlx/runtime-async-std-native-tls", "sea-schema/runtime-async-std-native-tls" ] From e76cbb9fe19be1d89e2c4d540785d71deaf1484f Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Sun, 16 Oct 2022 19:02:48 +0800 Subject: [PATCH 68/71] Add `into_model` & `into_json` for `Cursor` (#1112) --- src/executor/cursor.rs | 29 +++++++++++++++++++++++++++++ tests/cursor_tests.rs | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/executor/cursor.rs b/src/executor/cursor.rs index 264b7dc6..e690747c 100644 --- a/src/executor/cursor.rs +++ b/src/executor/cursor.rs @@ -8,6 +8,9 @@ use sea_query::{ }; use std::marker::PhantomData; +#[cfg(feature = "with-json")] +use crate::JsonValue; + /// Cursor pagination #[derive(Debug, Clone)] pub struct Cursor @@ -140,6 +143,32 @@ where } Ok(buffer) } + + /// Construct a [Cursor] that fetch any custom struct + pub fn into_model(self) -> Cursor> + where + M: FromQueryResult, + { + Cursor { + query: self.query, + table: self.table, + order_columns: self.order_columns, + last: self.last, + phantom: PhantomData, + } + } + + /// Construct a [Cursor] that fetch JSON value + #[cfg(feature = "with-json")] + pub fn into_json(self) -> Cursor> { + Cursor { + query: self.query, + table: self.table, + order_columns: self.order_columns, + last: self.last, + phantom: PhantomData, + } + } } impl QueryOrder for Cursor diff --git a/tests/cursor_tests.rs b/tests/cursor_tests.rs index 19790205..e6feef9f 100644 --- a/tests/cursor_tests.rs +++ b/tests/cursor_tests.rs @@ -2,7 +2,8 @@ pub mod common; pub use common::{features::*, setup::*, TestContext}; use pretty_assertions::assert_eq; -use sea_orm::entity::prelude::*; +use sea_orm::{entity::prelude::*, FromQueryResult}; +use serde_json::json; #[sea_orm_macros::test] #[cfg(any( @@ -199,5 +200,38 @@ pub async fn cursor_pagination(db: &DatabaseConnection) -> Result<(), DbErr> { vec![Model { id: 6 }, Model { id: 7 }] ); + // Fetch custom struct + + #[derive(FromQueryResult, Debug, PartialEq)] + struct Row { + id: i32, + } + + let mut cursor = cursor.into_model::(); + + assert_eq!( + cursor.first(2).all(db).await?, + vec![Row { id: 6 }, Row { id: 7 }] + ); + + assert_eq!( + cursor.first(3).all(db).await?, + vec![Row { id: 6 }, Row { id: 7 }] + ); + + // Fetch JSON value + + let mut cursor = cursor.into_json(); + + assert_eq!( + cursor.first(2).all(db).await?, + vec![json!({ "id": 6 }), json!({ "id": 7 })] + ); + + assert_eq!( + cursor.first(3).all(db).await?, + vec![json!({ "id": 6 }), json!({ "id": 7 })] + ); + Ok(()) } From cb3e69d9f97be0bfb289883722aa2f865207e1eb Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sun, 16 Oct 2022 19:05:22 +0800 Subject: [PATCH 69/71] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ecc176..538a9a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * `migrate fresh` command will drop all PostgreSQL types https://github.com/SeaQL/sea-orm/pull/864, https://github.com/SeaQL/sea-orm/pull/991 * Better compile error for entity without primary key https://github.com/SeaQL/sea-orm/pull/1020 * Added blanket implementations of `IntoActiveValue` for `Option` values https://github.com/SeaQL/sea-orm/pull/833 +* Added `into_model` & `into_json` to `Cursor` https://github.com/SeaQL/sea-orm/pull/1112 ### Bug fixes From b22db842e4116f03948c8a08af3647a65e49936e Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Mon, 17 Oct 2022 17:16:00 +0800 Subject: [PATCH 70/71] Run migrations on PostgreSQL schema (#1056) * Run migrations on PostgreSQL schema * fmt * fmt & clippy * clippy * [cli] update helper text --- sea-orm-cli/src/bin/main.rs | 12 +++++++-- sea-orm-cli/src/bin/sea.rs | 12 +++++++-- sea-orm-cli/src/cli.rs | 22 +++++++++++++++++ sea-orm-cli/src/commands/migrate.rs | 18 ++++++++------ sea-orm-migration/src/cli.rs | 38 ++++++++++++++++++++++++++--- sea-orm-migration/tests/main.rs | 37 +++++++++++++++++++++++----- src/database/mod.rs | 9 +++++++ src/driver/sqlx_postgres.rs | 17 ++++++++++++- 8 files changed, 143 insertions(+), 22 deletions(-) diff --git a/sea-orm-cli/src/bin/main.rs b/sea-orm-cli/src/bin/main.rs index e9d40401..e847d003 100644 --- a/sea-orm-cli/src/bin/main.rs +++ b/sea-orm-cli/src/bin/main.rs @@ -17,8 +17,16 @@ async fn main() { } Commands::Migrate { migration_dir, + database_schema, + database_url, command, - } => run_migrate_command(command, migration_dir.as_str(), verbose) - .unwrap_or_else(handle_error), + } => run_migrate_command( + command, + &migration_dir, + database_schema, + database_url, + verbose, + ) + .unwrap_or_else(handle_error), } } diff --git a/sea-orm-cli/src/bin/sea.rs b/sea-orm-cli/src/bin/sea.rs index 4a8b3d14..edf15c83 100644 --- a/sea-orm-cli/src/bin/sea.rs +++ b/sea-orm-cli/src/bin/sea.rs @@ -19,8 +19,16 @@ async fn main() { } Commands::Migrate { migration_dir, + database_schema, + database_url, command, - } => run_migrate_command(command, migration_dir.as_str(), verbose) - .unwrap_or_else(handle_error), + } => run_migrate_command( + command, + &migration_dir, + database_schema, + database_url, + verbose, + ) + .unwrap_or_else(handle_error), } } diff --git a/sea-orm-cli/src/cli.rs b/sea-orm-cli/src/cli.rs index 827a17e1..ebbbf6e9 100644 --- a/sea-orm-cli/src/cli.rs +++ b/sea-orm-cli/src/cli.rs @@ -34,6 +34,28 @@ you should provide the directory of that submodule.", )] migration_dir: String, + #[clap( + value_parser, + global = true, + short = 's', + long, + env = "DATABASE_SCHEMA", + long_help = "Database schema\n \ + - For MySQL and SQLite, this argument is ignored.\n \ + - For PostgreSQL, this argument is optional with default value 'public'.\n" + )] + database_schema: Option, + + #[clap( + value_parser, + global = true, + short = 'u', + long, + env = "DATABASE_URL", + help = "Database URL" + )] + database_url: Option, + #[clap(subcommand)] command: Option, }, diff --git a/sea-orm-cli/src/commands/migrate.rs b/sea-orm-cli/src/commands/migrate.rs index ac612a59..1ab5f33a 100644 --- a/sea-orm-cli/src/commands/migrate.rs +++ b/sea-orm-cli/src/commands/migrate.rs @@ -13,6 +13,8 @@ use crate::MigrateSubcommands; pub fn run_migrate_command( command: Option, migration_dir: &str, + database_schema: Option, + database_url: Option, verbose: bool, ) -> Result<(), Box> { match command { @@ -41,20 +43,20 @@ pub fn run_migrate_command( format!("{}/Cargo.toml", migration_dir) }; // Construct the arguments that will be supplied to `cargo` command - let mut args = vec![ - "run", - "--manifest-path", - manifest_path.as_str(), - "--", - subcommand, - ]; + let mut args = vec!["run", "--manifest-path", &manifest_path, "--", subcommand]; let mut num: String = "".to_string(); if let Some(steps) = steps { num = steps.to_string(); } if !num.is_empty() { - args.extend(["-n", num.as_str()]) + args.extend(["-n", &num]) + } + if let Some(database_url) = &database_url { + args.extend(["-u", database_url]); + } + if let Some(database_schema) = &database_schema { + args.extend(["-s", database_schema]); } if verbose { args.push("-v"); diff --git a/sea-orm-migration/src/cli.rs b/sea-orm-migration/src/cli.rs index d098cb81..93c7ef38 100644 --- a/sea-orm-migration/src/cli.rs +++ b/sea-orm-migration/src/cli.rs @@ -3,7 +3,7 @@ use dotenvy::dotenv; use std::{error::Error, fmt::Display, process::exit}; use tracing_subscriber::{prelude::*, EnvFilter}; -use sea_orm::{Database, DbConn}; +use sea_orm::{ConnectOptions, Database, DbConn}; use sea_orm_cli::{run_migrate_generate, run_migrate_init, MigrateSubcommands}; use super::MigratorTrait; @@ -15,10 +15,20 @@ where M: MigratorTrait, { dotenv().ok(); - let url = std::env::var("DATABASE_URL").expect("Environment variable 'DATABASE_URL' not set"); - let db = &Database::connect(&url).await.unwrap(); let cli = Cli::parse(); + let url = cli + .database_url + .expect("Environment variable 'DATABASE_URL' not set"); + let schema = cli.database_schema.unwrap_or_else(|| "public".to_owned()); + + let connect_options = ConnectOptions::new(url) + .set_schema_search_path(schema) + .to_owned(); + let db = &Database::connect(connect_options) + .await + .expect("Fail to acquire database connection"); + run_migrate(migrator, db, cli.command, cli.verbose) .await .unwrap_or_else(handle_error); @@ -81,6 +91,28 @@ pub struct Cli { #[clap(action, short = 'v', long, global = true, help = "Show debug messages")] verbose: bool, + #[clap( + value_parser, + global = true, + short = 's', + long, + env = "DATABASE_SCHEMA", + long_help = "Database schema\n \ + - For MySQL and SQLite, this argument is ignored.\n \ + - For PostgreSQL, this argument is optional with default value 'public'.\n" + )] + database_schema: Option, + + #[clap( + value_parser, + global = true, + short = 'u', + long, + env = "DATABASE_URL", + help = "Database URL" + )] + database_url: Option, + #[clap(subcommand)] command: Option, } diff --git a/sea-orm-migration/tests/main.rs b/sea-orm-migration/tests/main.rs index 2dfcf93d..fe006c47 100644 --- a/sea-orm-migration/tests/main.rs +++ b/sea-orm-migration/tests/main.rs @@ -1,7 +1,7 @@ mod migrator; use migrator::Migrator; -use sea_orm::{ConnectionTrait, Database, DbBackend, DbErr, Statement}; +use sea_orm::{ConnectOptions, ConnectionTrait, Database, DbBackend, DbErr, Statement}; use sea_orm_migration::prelude::*; #[async_std::test] @@ -11,9 +11,26 @@ async fn main() -> Result<(), DbErr> { .with_test_writer() .init(); - let url = std::env::var("DATABASE_URL").expect("Environment variable 'DATABASE_URL' not set"); - let db_name = "sea_orm_migration"; - let db = Database::connect(&url).await?; + let url = &std::env::var("DATABASE_URL").expect("Environment variable 'DATABASE_URL' not set"); + + run_migration(url, "sea_orm_migration", "public").await?; + + run_migration(url, "sea_orm_migration_schema", "my_schema").await?; + + Ok(()) +} + +async fn run_migration(url: &str, db_name: &str, schema: &str) -> Result<(), DbErr> { + let db_connect = |url: String| async { + let connect_options = ConnectOptions::new(url) + .set_schema_search_path(schema.to_owned()) + .to_owned(); + + Database::connect(connect_options).await + }; + + let db = db_connect(url.to_owned()).await?; + let db = &match db.get_database_backend() { DbBackend::MySql => { db.execute(Statement::from_string( @@ -23,7 +40,7 @@ async fn main() -> Result<(), DbErr> { .await?; let url = format!("{}/{}", url, db_name); - Database::connect(&url).await? + db_connect(url).await? } DbBackend::Postgres => { db.execute(Statement::from_string( @@ -38,7 +55,15 @@ async fn main() -> Result<(), DbErr> { .await?; let url = format!("{}/{}", url, db_name); - Database::connect(&url).await? + let db = db_connect(url).await?; + + db.execute(Statement::from_string( + db.get_database_backend(), + format!("CREATE SCHEMA IF NOT EXISTS \"{}\";", schema), + )) + .await?; + + db } DbBackend::Sqlite => db, }; diff --git a/src/database/mod.rs b/src/database/mod.rs index 1a1a399e..795789e6 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -48,6 +48,8 @@ pub struct ConnectOptions { pub(crate) sqlx_logging_level: log::LevelFilter, /// set sqlcipher key pub(crate) sqlcipher_key: Option>, + /// Schema search path (PostgreSQL only) + pub(crate) schema_search_path: Option, } impl Database { @@ -114,6 +116,7 @@ impl ConnectOptions { sqlx_logging: true, sqlx_logging_level: log::LevelFilter::Info, sqlcipher_key: None, + schema_search_path: None, } } @@ -251,4 +254,10 @@ impl ConnectOptions { self.sqlcipher_key = Some(value.into()); self } + + /// Set schema search path (PostgreSQL only) + pub fn set_schema_search_path(&mut self, schema_search_path: String) -> &mut Self { + self.schema_search_path = Some(schema_search_path); + self + } } diff --git a/src/driver/sqlx_postgres.rs b/src/driver/sqlx_postgres.rs index 085abaa8..72432435 100644 --- a/src/driver/sqlx_postgres.rs +++ b/src/driver/sqlx_postgres.rs @@ -52,7 +52,22 @@ impl SqlxPostgresConnector { } else { opt.log_statements(options.sqlx_logging_level); } - match options.pool_options().connect_with(opt).await { + let set_search_path_sql = options + .schema_search_path + .as_ref() + .map(|schema| format!("SET search_path = '{}'", schema)); + let mut pool_options = options.pool_options(); + if let Some(sql) = set_search_path_sql { + pool_options = pool_options.after_connect(move |conn, _| { + let sql = sql.clone(); + Box::pin(async move { + sqlx::Executor::execute(conn, sql.as_str()) + .await + .map(|_| ()) + }) + }); + } + match pool_options.connect_with(opt).await { Ok(pool) => Ok(DatabaseConnection::SqlxPostgresPoolConnection( SqlxPostgresPoolConnection { pool, From 305dc00f9676dd9bd2e9f26605f11c1fcb4d7bb1 Mon Sep 17 00:00:00 2001 From: Billy Chan Date: Mon, 17 Oct 2022 17:29:03 +0800 Subject: [PATCH 71/71] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 538a9a01..69c17f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * [sea-orm-cli] Generate migration in modules https://github.com/SeaQL/sea-orm/pull/933 * [sea-orm-cli] Generate `DeriveRelation` on empty `Relation` enum https://github.com/SeaQL/sea-orm/pull/1019 * [sea-orm-cli] Generate entity derive `Eq` if possible https://github.com/SeaQL/sea-orm/pull/988 +* Run migration on any PostgreSQL schema https://github.com/SeaQL/sea-orm/pull/1056 ### Enhancements @@ -24,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Better compile error for entity without primary key https://github.com/SeaQL/sea-orm/pull/1020 * Added blanket implementations of `IntoActiveValue` for `Option` values https://github.com/SeaQL/sea-orm/pull/833 * Added `into_model` & `into_json` to `Cursor` https://github.com/SeaQL/sea-orm/pull/1112 +* Added `set_schema_search_path` method to `ConnectOptions` for setting schema search path of PostgreSQL connection https://github.com/SeaQL/sea-orm/pull/1056 ### Bug fixes