Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ colored = { workspace = true }
sea-orm = { version = "1.1.0", features = [
"sqlx-postgres", # `DATABASE_DRIVER` feature
"sqlx-sqlite",
"sqlx-mysql",
"runtime-tokio-rustls",
"macros",
], optional = true }
Expand Down Expand Up @@ -213,6 +214,7 @@ sqlx = { version = "0.8.2", default-features = false, features = [
"postgres",
"chrono",
"sqlite",
"mysql",
"migrate",
] }
testcontainers = { version = "0.23.3" }
1 change: 1 addition & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ pub struct LoggerFileAppender {
pub struct Database {
/// The URI for connecting to the database. For example:
/// * Postgres: `postgres://root:12341234@localhost:5432/myapp_development`
/// * MySQL: `mysql://root:12341234@localhost:3306/myapp_development`
/// * Sqlite: `sqlite://db.sqlite?mode=rwc`
pub uri: String,

Expand Down
153 changes: 142 additions & 11 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,20 @@ async fn has_id_column(
result.is_some_and(|row| row.try_get::<i32>("", "count").unwrap_or(0) > 0)
}
DatabaseBackend::MySql => {
return Err(Error::Message(
"Unsupported database backend: MySQL".to_string(),
))

let query = format!(
"SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = '{table_name}'
AND column_name = 'id'"
);
let result = db
.query_one(Statement::from_string(DatabaseBackend::MySql, query))
.await?;

// MySQL returns count as i64 in many drivers
result.is_some_and(|row| row.try_get::<i64>("", "count").unwrap_or(0) > 0)
}
};

Expand Down Expand Up @@ -396,9 +407,21 @@ async fn is_auto_increment(
})
}
DatabaseBackend::MySql => {
return Err(Error::Message(
"Unsupported database backend: MySQL".to_string(),
))
let query = format!(
"SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = '{table_name}'
AND column_name = 'id'
AND extra LIKE '%auto_increment%'"
);
let result = db
.query_one(Statement::from_string(DatabaseBackend::MySql, query))
.await?;

// Check if count > 0. Note: MySQL drivers often return i64 for COUNT(*)
result.is_some_and(|row| row.try_get::<i64>("", "count").unwrap_or(0) > 0)

}
};
Ok(result)
Expand Down Expand Up @@ -449,9 +472,16 @@ pub async fn reset_autoincrement(
.await?;
}
DatabaseBackend::MySql => {
return Err(Error::Message(
"Unsupported database backend: MySQL".to_string(),
// In MySQL, setting AUTO_INCREMENT to 1 is a standard way to reset it.
// MySQL will automatically adjust this value to MAX(id) + 1 when
// the next record is inserted.
let query_str = format!("ALTER TABLE `{table_name}` AUTO_INCREMENT = 1");
db.execute(Statement::from_string(
DatabaseBackend::MySql,
query_str,
))
.await?;

}
}
Ok(())
Expand Down Expand Up @@ -765,7 +795,7 @@ pub async fn get_tables(db: &DatabaseConnection) -> AppResult<Vec<String>> {
let query = match db.get_database_backend() {
DatabaseBackend::MySql => {
return Err(Error::Message(
"Unsupported database backend: MySQL".to_string(),
"Unsupported database backend for get_tables: MySQL".to_string(),
))
}
DatabaseBackend::Postgres => {
Expand Down Expand Up @@ -1003,7 +1033,7 @@ pub async fn dump_schema(ctx: &AppContext, fname: &str) -> crate::Result<()> {
mod tests {
use super::*;
use crate::tests_cfg::{
config::get_database_config, db::get_value, postgres::setup_postgres_container,
config::get_database_config, db::get_value, postgres::setup_postgres_container, mysql::setup_mysql_container,
};

#[tokio::test]
Expand Down Expand Up @@ -1041,6 +1071,25 @@ mod tests {
assert_eq!(db.get_database_backend(), DatabaseBackend::Postgres);
}

#[tokio::test]
async fn test_mysql_connect_success() {
let (mysql_url, _container) = setup_mysql_container().await;
let mut config = crate::tests_cfg::config::get_database_config();
config.uri = mysql_url;
config.min_connections = 1;
config.max_connections = 5;

let result = connect(&config).await;
assert!(
result.is_ok(),
"Failed to connect to MySQL: {:?}",
result.err()
);

let db = result.unwrap();
assert_eq!(db.get_database_backend(), DatabaseBackend::MySql);
}

#[tokio::test]
async fn test_sqlite_default_run_on_start() {
let (config, _tree_fs) = crate::tests_cfg::config::get_sqlite_test_config("test");
Expand Down Expand Up @@ -1239,7 +1288,7 @@ mod tests {
"Test database '{test_db_name}' not exists"
);
}

#[tokio::test]
async fn test_postgres_has_id_column() {
let (pg_url, _container) = setup_postgres_container().await;
Expand Down Expand Up @@ -1358,6 +1407,46 @@ mod tests {
);
}

#[tokio::test]
async fn test_mysql_has_id_column() {
let (mysql_url, _container) = setup_mysql_container().await;
let mut config = crate::tests_cfg::config::get_database_config();
config.uri = mysql_url;
let db = connect(&config)
.await
.expect("Failed to connect to MySQL");
let backend = db.get_database_backend();

// Table without ID
let table_no_id = "test_table_no_id";
db.execute(Statement::from_string(
backend,
format!("CREATE TABLE `{table_no_id}` (name TEXT);"),
))
.await
.expect("Failed to create table without id");

let has_id = has_id_column(&db, &backend, table_no_id)
.await
.expect("Failed to check for id column");
assert!(!has_id, "Table should NOT have an 'id' column");

// Table with standard ID
let table_with_id = "test_table_with_id";
db.execute(Statement::from_string(
backend,
format!("CREATE TABLE `{table_with_id}` (id INTEGER PRIMARY KEY, name TEXT);"),
))
.await
.expect("Failed to create table with id");

let has_id = has_id_column(&db, &backend, table_with_id)
.await
.expect("Failed to check for id column");
assert!(has_id, "Table SHOULD have an 'id' column");
}


#[tokio::test]
async fn test_postgres_is_auto_increment() {
let (pg_url, _container) = setup_postgres_container().await;
Expand Down Expand Up @@ -1578,6 +1667,48 @@ mod tests {
assert_eq!(id, 1, "ID should be 1 after sequence reset");
}

#[tokio::test]
async fn test_mysql_reset_autoincrement() {
let (mysql_url, _container) = setup_mysql_container().await;
let mut config = crate::tests_cfg::config::get_database_config();
config.uri = mysql_url;
let db = connect(&config).await.unwrap();
let backend = db.get_database_backend();

let table_name = "test_reset_table";
db.execute(Statement::from_string(
backend,
format!("CREATE TABLE `{table_name}` (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255));"),
))
.await
.unwrap();

// Verify is_auto_increment detection
let auto_inc = is_auto_increment(&db, &backend, table_name).await.unwrap();
assert!(auto_inc, "Should detect auto_increment on MySQL");

// Insert row and reset
db.execute(Statement::from_string(
backend,
format!("INSERT INTO `{table_name}` (name) VALUES ('first');"),
))
.await
.unwrap();

// Reset sequence
reset_autoincrement(backend, table_name, &db).await.expect("Failed to reset");

// In MySQL, after a reset to 1, the next insert should get ID 1 (if table is empty)
// or MAX(id)+1. To truly test reset, we'd truncate then reset.
db.execute(Statement::from_string(backend, format!("TRUNCATE TABLE `{table_name}`;"))).await.unwrap();
reset_autoincrement(backend, table_name, &db).await.unwrap();

db.execute(Statement::from_string(backend, format!("INSERT INTO `{table_name}` (name) VALUES ('after_reset');"))).await.unwrap();
let last_id = get_value(&db, &format!("SELECT id FROM `{table_name}` LIMIT 1")).await;
assert_eq!(last_id, "1");
}


#[test]
fn test_entity_cmd_new() {
let cmd = EntityCmd::new(&get_database_config());
Expand Down
2 changes: 2 additions & 0 deletions src/tests_cfg/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pub mod controllers;
#[cfg(feature = "with-db")]
pub mod db;
#[cfg(test)]
pub mod mysql;
#[cfg(test)]
pub mod postgres;
#[cfg(any(feature = "bg_pg", feature = "bg_sqlt"))]
pub mod queue;
Expand Down
74 changes: 74 additions & 0 deletions src/tests_cfg/mysql.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use std::time::Duration;

use sqlx::MySqlPool;
use testcontainers::{
core::{logs::LogSource, wait::LogWaitStrategy, ContainerPort, WaitFor},
runners::AsyncRunner,
ContainerAsync, GenericImage, ImageExt,
};

/// Sets up a `MySQL` test container.
///
/// # Returns
///
/// A tuple containing the `MySQL` connection URL and the container instance.
///
/// # Panics
///
/// This function will panic if it fails to set up, start, or connect to the
/// MySQL container.
pub async fn setup_mysql_container() -> (String, ContainerAsync<GenericImage>) {
let mysql_image = GenericImage::new("mysql", "8")
.with_wait_for(WaitFor::log(LogWaitStrategy::new(
LogSource::StdErr,
"ready for connections",
)))
.with_exposed_port(ContainerPort::Tcp(3306))
.with_env_var("MYSQL_ROOT_PASSWORD", "mysql")
.with_env_var("MYSQL_DATABASE", "loco_test")
.with_env_var("MYSQL_USER", "loco")
.with_env_var("MYSQL_PASSWORD", "loco");

let container = mysql_image
.start()
.await
.expect("Failed to start MySQL container");

let host_port = container
.get_host_port_ipv4(3306)
.await
.expect("Failed to get host port");

// Construct the URL. Note: MySQL protocol usually takes the form
// mysql://user:pass@host:port/db
let mysql_url = format!("mysql://loco:[email protected]:{host_port}/loco_test");

// Connection retry logic (identical to your Postgres version)
let mut connected = false;

for attempt in 0..10 {
// Note: Requires sqlx with 'mysql' feature for testing
match MySqlPool::connect(&mysql_url).await {
Ok(pool) => match sqlx::query("SELECT 1").execute(&pool).await {
Ok(_) => {
connected = true;
break;
}
Err(_) => {
if attempt < 9 {
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
},
Err(_) => {
if attempt < 9 {
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}

assert!(connected, "Failed to connect to MySQL after 10 attempts");

(mysql_url, container)
}