From 829c4ef3e344753e40fb460881a678ed51e7465e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:26:31 +0800 Subject: [PATCH] Add CLI application with database migration and entity generation commands - Introduced a new CLI application in the `apps/cli` directory. - Implemented commands for database migration and entity generation. - Updated `Cargo.toml` files to include necessary dependencies. - Enhanced the `justfile` to facilitate CLI command execution. - Modified workspace configuration to include the new CLI application. --- Cargo.lock | 210 +++++++++++++++++++- Cargo.toml | 1 + apps/cli/Cargo.toml | 16 ++ apps/cli/src/cmd.rs | 45 +++++ apps/cli/src/cmd/db_migrate_and_generate.rs | 169 ++++++++++++++++ apps/cli/src/main.rs | 22 ++ justfile | 19 +- public/migration/Cargo.toml | 1 + public/migration/src/lib.rs | 47 +++++ 9 files changed, 517 insertions(+), 13 deletions(-) create mode 100644 apps/cli/Cargo.toml create mode 100644 apps/cli/src/cmd.rs create mode 100644 apps/cli/src/cmd/db_migrate_and_generate.rs create mode 100644 apps/cli/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 2eac8c2..9fec9ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.16", "once_cell", "version_check", ] @@ -410,6 +410,22 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cli" +version = "0.1.0" +dependencies = [ + "async-trait", + "clap", + "container-simulate", + "migration", + "once_cell", + "path-clean", + "shared", + "testcontainers", + "tokio", + "url", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -444,6 +460,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -737,6 +763,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filetime" version = "0.2.26" @@ -778,6 +810,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -914,6 +961,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "glob" version = "0.3.3" @@ -1440,6 +1499,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" name = "migration" version = "0.1.0" dependencies = [ + "sea-orm-cli", "sea-orm-migration", "tokio", ] @@ -1455,6 +1515,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1529,12 +1606,50 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -1691,6 +1806,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1715,6 +1840,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.110", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1797,6 +1932,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -1830,7 +1971,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1917,7 +2058,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", @@ -2024,7 +2165,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -2164,6 +2305,7 @@ dependencies = [ "glob", "indoc", "regex", + "sea-orm-codegen", "sea-schema", "sqlx", "tokio", @@ -2172,6 +2314,22 @@ dependencies = [ "url", ] +[[package]] +name = "sea-orm-codegen" +version = "2.0.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "004eff55e5aa46d43ef4f1b3650cef08ae74305c029a52ab036e36fd3ccbd44b" +dependencies = [ + "heck 0.5.0", + "pluralizer", + "prettyplease", + "proc-macro2", + "quote", + "sea-query", + "syn 2.0.110", + "tracing", +] + [[package]] name = "sea-orm-macros" version = "2.0.0-rc.18" @@ -2280,6 +2438,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -2287,7 +2458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2546,6 +2717,7 @@ dependencies = [ "indexmap 2.12.0", "log", "memchr", + "native-tls", "once_cell", "percent-encoding", "rust_decimal", @@ -2822,6 +2994,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "testcontainers" version = "0.24.0" @@ -3209,6 +3394,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -3609,6 +3803,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 481e597..d44d56d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "apps/container", + "apps/cli", "public/shared", "public/database", "public/migration" diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml new file mode 100644 index 0000000..8357949 --- /dev/null +++ b/apps/cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1.89" +container-simulate = { path = "../container" } +migration = {path = "../../public/migration"} +shared = {path = "../../public/shared"} +testcontainers = "0.24.0" +tokio = { version = "1.47.0", features = ["full"] } +url = "2.5.7" +clap = { version = "4.5.48", features = ["derive", "env"] } +path-clean = "1.0.1" +once_cell = "1.21.3" diff --git a/apps/cli/src/cmd.rs b/apps/cli/src/cmd.rs new file mode 100644 index 0000000..97414bc --- /dev/null +++ b/apps/cli/src/cmd.rs @@ -0,0 +1,45 @@ +use std::pin::Pin; +use std::{future::Future, process::exit}; + +use clap::{ArgMatches, Command}; + +pub mod db_migrate_and_generate; + +pub struct CliCommand { + pub command: Command, + pub action: fn(&clap::ArgMatches) -> Pin + Send>>, +} + +static CLI_COMMANDS: once_cell::sync::Lazy< + [CliCommand; 1 /* Update this count when adding new commands */], +> = + once_cell::sync::Lazy::new(|| { + [ + // Add new commands here + db_migrate_and_generate::get_cli_command(), + ] + }); + +pub fn get_command() -> Command { + let mut c = Command::new("cmd"); + + for cmd in CLI_COMMANDS.iter() { + c = c.subcommand(cmd.command.clone()); + } + + c +} + +pub fn execute(matches: &ArgMatches, help_msg: &str) -> Pin + Send>> { + if let Some((subcommand_name, subcommand_matches)) = matches.subcommand() { + for cmd in CLI_COMMANDS.iter() { + if cmd.command.get_name() == subcommand_name { + return (cmd.action)(subcommand_matches); + } + } + } + + eprintln!("Error: No valid subcommand provided."); + eprintln!("{}", help_msg); + exit(1); +} diff --git a/apps/cli/src/cmd/db_migrate_and_generate.rs b/apps/cli/src/cmd/db_migrate_and_generate.rs new file mode 100644 index 0000000..034d1d3 --- /dev/null +++ b/apps/cli/src/cmd/db_migrate_and_generate.rs @@ -0,0 +1,169 @@ +use clap::{Arg, Command}; +use container::{ + db::{DBInfo, sqlite::SQLiteContainer}, + types::ConfigInfoType, +}; +use migration::{generate_entity, migrate_database}; +use shared::db_type::DBType; + +use crate::cmd::CliCommand; + +const MAX_DB_READY_ATTEMPTS: u8 = 10; +const DB_READY_CHECK_INTERVAL_SECS: u64 = 2; +const DB_READY_STRING: [&str; 1] = ["ready to accept connections"]; + +pub fn get_cli_command() -> CliCommand { + CliCommand { + command: command(), + action: action, + } +} + +fn command() -> Command { + Command::new("db:migrate_and_generate") + .arg( + Arg::new("output_path") + .short('o') + .long("output-path") + .value_name("PATH") + .help("Path to output the generated entity schema") + .required(true), + ) + .about("Migrate database and generate entity schema") +} + +fn action( + _matches: &clap::ArgMatches, +) -> std::pin::Pin + Send>> { + let output_path = _matches.get_one::("output_path"); + let output_path = output_path.unwrap().to_string(); + Box::pin(async move { + let mut error_occurred = false; + let database_configs = vec![ + SQLiteContainer::new(None) + .await + .get_db_container_config_info() + .await, + // only sqlite is required to generate entities when using Seaorm + // PostgreSQLContainer::new(None) + // .await + // .get_db_container_config_info() + // .await, + ]; + + for db_config in database_configs { + let config = container::Config { + database: db_config, + }; + let mut detached_handler = container::start_detached(&config).await; + match migrate_and_generate_entity(&config, &output_path).await { + Ok(_) => println!("Migration and entity generation succeeded."), + Err(_) => { + eprintln!("Migration and entity generation failed."); + error_occurred = true; + break; + } + } + detached_handler.stop().await; + } + + if error_occurred { + std::process::exit(1); + } else { + std::process::exit(0); + } + }) +} + +async fn migrate_and_generate_entity( + config: &container::Config, + output_path: &str, +) -> Result<(), ()> { + let ready_result = await_database_ready(&config).await; + if ready_result.is_err() { + eprintln!("Database did not become ready in time."); + return Err(()); + } + + let db_url = match &config.database { + ConfigInfoType::Containerized(container_info) => &container_info.url, + ConfigInfoType::PreExisting(pre_existing_info) => &pre_existing_info.url, + }; + + let db_type = get_database_type(&config); + match migrate_database(db_url).await { + Ok(_) => { + println!("Database migrated successfully for {:?}", db_type); + } + Err(e) => { + eprintln!("Failed to migrate database for {}: {:#?}", db_type, e); + return Err(()); + } + } + + match generate_entity(db_url, output_path).await { + Ok(_) => { + println!( + "Database entity schema generated successfully for {:?}", + db_type + ); + } + Err(e) => { + eprintln!( + "Failed to generate database entity schema for {}: {:#?}", + db_type, e + ); + return Err(()); + } + } + + Ok(()) +} + +fn get_database_type(config: &container::Config) -> DBType { + match config.database { + ConfigInfoType::Containerized(ref container_info) => container_info.db_type.clone(), + ConfigInfoType::PreExisting(ref pre_existing_info) => pre_existing_info.db_type.clone(), + } +} + +async fn await_database_ready(config: &container::Config) -> Result<(), ()> { + match config.database { + ConfigInfoType::Containerized(ref container_info) => { + let container_type = &container_info.db_type; + for attempt in 1..=MAX_DB_READY_ATTEMPTS { + println!( + "Checking if database container {} is ready (attempt {}/{})...", + container_info.db_type, attempt, MAX_DB_READY_ATTEMPTS + ); + let logs = match container_info.container.stdout_to_vec().await { + Ok(logs) => logs, + Err(e) => { + eprintln!("Failed to get container logs: {}", e); + return Err(()); + } + }; + let log_output = String::from_utf8_lossy(&logs); + if DB_READY_STRING.iter().any(|&s| log_output.contains(s)) { + println!("Database container {} is ready.", container_info.db_type); + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_secs(DB_READY_CHECK_INTERVAL_SECS)) + .await; + } + eprintln!( + "Database container {} did not become ready after {} attempts.", + container_type, MAX_DB_READY_ATTEMPTS + ); + Err(()) + } + + ConfigInfoType::PreExisting(ref pre_existing_info) => { + println!( + "Pre-existing database of type {} assumed to be ready.", + pre_existing_info.db_type + ); + return Ok(()); + } + } +} diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs new file mode 100644 index 0000000..fdb350c --- /dev/null +++ b/apps/cli/src/main.rs @@ -0,0 +1,22 @@ +mod cmd; + +use clap::error::ErrorKind; + +#[tokio::main] +async fn main() { + let mut command = cmd::get_command(); + let help_output = format!("{}", command.render_help()); + let matches = command + .try_get_matches() + .unwrap_or_else(|err| match err.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + err.print().expect("Error writing Error"); + std::process::exit(0); + } + _ => { + err.print().expect("Error writing Error"); + std::process::exit(1); + } + }); + cmd::execute(&matches, &help_output).await; +} diff --git a/justfile b/justfile index 20d3500..2b3e71e 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,15 @@ set dotenv-load := true +# development environment file set dotenv-filename := "./public/database/.env.generated" +cli *args: + cd apps/cli && \ + if [ -n "{{args}}" ]; then \ + cargo run -- {{args}}; \ + else \ + cargo run; \ + fi + simulate *args: cd apps/container && \ if [ -n "{{args}}" ]; then \ @@ -29,11 +38,5 @@ migrate *args: fi generate-entity: - # load development environment variables - # sea-orm-cli will also load .env file by default - cd public/migration && \ - sea-orm-cli generate entity \ - -o ../database/src/generated/entities \ - --with-serde both \ - --date-time-crate chrono - + # delegate to cli + just cli db:migrate_and_generate --output-path ../../public/database/src/generated/entities diff --git a/public/migration/Cargo.toml b/public/migration/Cargo.toml index 8e97ae7..8241205 100644 --- a/public/migration/Cargo.toml +++ b/public/migration/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } +sea-orm-cli = { version = "2.0.0-rc", features = ["sqlx-postgres", "sqlx-mysql", "sqlx-sqlite", "runtime-tokio"] } [dependencies.sea-orm-migration] version = "2.0.0-rc" diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index 584b5ff..7488856 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*; mod migrations; use migrations::*; +use sea_orm_migration::sea_orm::Database; pub struct Migrator; @@ -14,3 +15,49 @@ impl MigratorTrait for Migrator { ] } } + +pub async fn migrate_database(db_url: &str) -> Result<(), DbErr> { + let db = Database::connect(db_url).await?; + Migrator::up(&db, None).await +} + +pub async fn generate_entity( + db_url: &str, + output_dir: &str, +) -> Result<(), Box> { + use sea_orm_cli::commands::generate::run_generate_command; + run_generate_command( + sea_orm_cli::GenerateSubcommands::Entity { + compact_format: true, + expanded_format: false, + frontend_format: false, + include_hidden_tables: false, + tables: vec![], + ignore_tables: vec!["seaql_migrations".to_string()], + max_connections: 1, + acquire_timeout: 30, + output_dir: output_dir.to_string(), + database_schema: Some("public".to_string()), + database_url: db_url.to_string(), + with_prelude: "all".to_string(), + with_serde: "both".to_string(), + serde_skip_deserializing_primary_key: false, + serde_skip_hidden_column: false, + with_copy_enums: true, + date_time_crate: sea_orm_cli::DateTimeCrate::Chrono, + lib: false, + model_extra_derives: vec![], + model_extra_attributes: vec![], + enum_extra_derives: vec![], + enum_extra_attributes: vec![], + seaography: false, + impl_active_model_behavior: true, + big_integer_type: sea_orm_cli::BigIntegerType::I64, + column_extra_derives: vec![], + entity_format: Some("dense".to_string()), + preserve_user_modifications: false, + }, + false, + ) + .await +}