diff --git a/.changepacks/changepack_log_2NL204bItgSvY-wobOr_S.json b/.changepacks/changepack_log_2NL204bItgSvY-wobOr_S.json new file mode 100644 index 0000000..d8a021c --- /dev/null +++ b/.changepacks/changepack_log_2NL204bItgSvY-wobOr_S.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch"},"note":"to async","date":"2026-02-05T15:07:14.923855200Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_LafjWo8RJnDa51cvEjEqo.json b/.changepacks/changepack_log_LafjWo8RJnDa51cvEjEqo.json new file mode 100644 index 0000000..101b78b --- /dev/null +++ b/.changepacks/changepack_log_LafjWo8RJnDa51cvEjEqo.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch"},"note":"Refactor rename","date":"2026-02-05T15:06:59.546818200Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ca4a8c6..3821693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,7 +105,9 @@ version = "0.1.0" dependencies = [ "anyhow", "sea-orm", + "serde", "tokio", + "vespera", "vespertide", ] @@ -130,6 +132,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -172,12 +185,99 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "fastrand", + "form_urlencoded", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -337,8 +437,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -437,6 +539,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -647,6 +760,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -759,6 +881,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -838,6 +975,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -923,6 +1061,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.1" @@ -968,6 +1130,87 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -1235,6 +1478,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -1251,6 +1500,12 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -1262,6 +1517,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2218,6 +2490,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -2231,6 +2516,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2659,6 +2955,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -2840,6 +3142,34 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2970,9 +3300,49 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vespera" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b836e7d783c5af8707a05899513bf398e8b189d5ca40ed0aed21fc1c8d21f1f3" +dependencies = [ + "axum", + "axum-extra", + "chrono", + "serde_json", + "tower-layer", + "tower-service", + "vespera_core", + "vespera_macro", +] + +[[package]] +name = "vespera_core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11305b0fa2573d6c547eb4af1b8a056afe060ed7ca9d194aff1699d3c57c3f6f" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "vespera_macro" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8605bc1b462c626b30e71f936d240fe7284e063be9bfaf0c384630b288cc178c" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.114", + "vespera_core", +] + [[package]] name = "vespertide" -version = "0.1.41" +version = "0.1.42" dependencies = [ "vespertide-core", "vespertide-macro", @@ -2980,14 +3350,16 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.41" +version = "0.1.42" dependencies = [ "anyhow", "assert_cmd", + "async-recursion", "chrono", "clap", "colored", "dialoguer", + "futures", "predicates", "rstest", "schemars", @@ -2995,6 +3367,7 @@ dependencies = [ "serde_yaml", "serial_test", "tempfile", + "tokio", "vespertide-config", "vespertide-core", "vespertide-exporter", @@ -3005,7 +3378,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.41" +version = "0.1.42" dependencies = [ "clap", "schemars", @@ -3015,7 +3388,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.41" +version = "0.1.42" dependencies = [ "rstest", "schemars", @@ -3027,18 +3400,19 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.41" +version = "0.1.42" dependencies = [ "insta", "rstest", "thiserror", "vespertide-config", "vespertide-core", + "vespertide-naming", ] [[package]] name = "vespertide-loader" -version = "0.1.41" +version = "0.1.42" dependencies = [ "anyhow", "rstest", @@ -3053,7 +3427,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.41" +version = "0.1.42" dependencies = [ "proc-macro2", "quote", @@ -3070,11 +3444,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.41" +version = "0.1.42" [[package]] name = "vespertide-planner" -version = "0.1.41" +version = "0.1.42" dependencies = [ "insta", "rstest", @@ -3085,7 +3459,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.41" +version = "0.1.42" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 8a86132..4b6918f 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -18,6 +18,9 @@ dialoguer = "0.12" serde_json = "1" serde_yaml = "0.9" schemars = "1.2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] } +futures = "0.3" +async-recursion = "1" vespertide-config = { workspace = true } vespertide-core = { workspace = true } vespertide-loader = { workspace = true } diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index f77492f..384d9ca 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -5,7 +5,7 @@ use vespertide_planner::plan_next_migration; use crate::utils::{load_config, load_migrations, load_models}; use vespertide_core::MigrationAction; -pub fn cmd_diff() -> Result<()> { +pub async fn cmd_diff() -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; let applied_plans = load_migrations(&config)?; @@ -568,7 +568,8 @@ mod tests { #[rstest] #[serial] - fn cmd_diff_with_model_and_no_migrations() { + #[tokio::test] + async fn cmd_diff_with_model_and_no_migrations() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -576,13 +577,14 @@ mod tests { write_model("users"); fs::create_dir_all("migrations").unwrap(); - let result = cmd_diff(); + let result = cmd_diff().await; assert!(result.is_ok()); } #[rstest] #[serial] - fn cmd_diff_when_no_changes() { + #[tokio::test] + async fn cmd_diff_when_no_changes() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -591,7 +593,7 @@ mod tests { fs::create_dir_all("models").unwrap(); fs::create_dir_all("migrations").unwrap(); - let result = cmd_diff(); + let result = cmd_diff().await; assert!(result.is_ok()); } diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index f5a0d59..8e25a6a 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -1,10 +1,9 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::ValueEnum; +use futures::future::try_join_all; +use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{Orm, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig}; @@ -28,9 +27,11 @@ impl From for Orm { } } -pub fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> { +pub async fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> { let config = load_config()?; - let models = load_models_recursive(config.models_dir()).context("load models recursively")?; + let models = load_models_recursive(config.models_dir()) + .await + .context("load models recursively")?; // Normalize tables to convert inline constraints (primary_key, foreign_key, etc.) to table-level constraints let normalized_models: Vec<(TableDef, PathBuf)> = models @@ -44,38 +45,71 @@ pub fn cmd_export(orm: OrmArg, export_dir: Option) -> Result<()> { .collect::, _>>()?; let target_root = resolve_export_dir(export_dir, &config); + + // Clean the export directory before regenerating + let orm_kind: Orm = orm.into(); + clean_export_dir(&target_root, orm_kind).await?; + if !target_root.exists() { fs::create_dir_all(&target_root) + .await .with_context(|| format!("create export dir {}", target_root.display()))?; } - let orm_kind: Orm = orm.into(); - // Extract all tables for schema context (used for FK chain resolution) let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); // Create SeaORM exporter with config if needed let seaorm_exporter = SeaOrmExporterWithConfig::new(config.seaorm(), config.prefix()); - for (table, rel_path) in &normalized_models { - let code = match orm_kind { - Orm::SeaOrm => seaorm_exporter - .render_entity_with_schema(table, &all_tables) - .map_err(|e| anyhow::anyhow!(e))?, - _ => render_entity_with_schema(orm_kind, table, &all_tables) - .map_err(|e| anyhow::anyhow!(e))?, - }; - let out_path = build_output_path(&target_root, rel_path, orm_kind); - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create parent dir {}", parent.display()))?; - } - fs::write(&out_path, code).with_context(|| format!("write {}", out_path.display()))?; - if matches!(orm_kind, Orm::SeaOrm) { + // Generate all entity code (CPU-bound, done synchronously) + let entities: Vec<(String, PathBuf, String)> = normalized_models + .iter() + .map(|(table, rel_path)| { + let code = match orm_kind { + Orm::SeaOrm => seaorm_exporter + .render_entity_with_schema(table, &all_tables) + .map_err(|e| anyhow::anyhow!(e)), + _ => render_entity_with_schema(orm_kind, table, &all_tables) + .map_err(|e| anyhow::anyhow!(e)), + }?; + let out_path = build_output_path(&target_root, rel_path, orm_kind); + Ok((table.name.clone(), out_path, code)) + }) + .collect::>>()?; + + // Write all files in parallel + let write_futures: Vec<_> = entities + .iter() + .map(|(name, out_path, code)| { + let name = name.clone(); + let out_path = out_path.clone(); + let code = code.clone(); + async move { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("create parent dir {}", parent.display()))?; + } + fs::write(&out_path, &code) + .await + .with_context(|| format!("write {}", out_path.display()))?; + println!("Exported {} -> {}", name, out_path.display()); + Ok::<(), anyhow::Error>(()) + } + }) + .collect(); + + try_join_all(write_futures).await?; + + // Ensure mod chain for SeaORM (must be done after all files are written) + if matches!(orm_kind, Orm::SeaOrm) { + for (_, rel_path) in &normalized_models { + let out_path = build_output_path(&target_root, rel_path, orm_kind); ensure_mod_chain(&target_root, rel_path) + .await .with_context(|| format!("ensure mod chain for {}", out_path.display()))?; } - println!("Exported {} -> {}", table.name, out_path.display()); } Ok(()) @@ -89,6 +123,78 @@ fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> config.model_export_dir().to_path_buf() } +/// Clean the export directory by removing all generated files. +/// This ensures no stale files remain from previous exports. +async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> { + if !root.exists() { + return Ok(()); + } + + let ext = match orm { + Orm::SeaOrm => "rs", + Orm::SqlAlchemy | Orm::SqlModel => "py", + }; + + clean_dir_recursive(root, ext).await?; + Ok(()) +} + +/// Recursively remove files with the given extension and empty directories. +/// Uses async I/O for parallel file operations. +async fn clean_dir_recursive(dir: &Path, ext: &str) -> Result<()> { + if !dir.is_dir() { + return Ok(()); + } + + let mut entries = fs::read_dir(dir) + .await + .with_context(|| format!("read dir {}", dir.display()))?; + + let mut subdirs = Vec::new(); + let mut files_to_remove = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() { + subdirs.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some(ext) { + files_to_remove.push(path); + } + } + + // Remove files in parallel + let remove_futures: Vec<_> = files_to_remove + .into_iter() + .map(|path| async move { + fs::remove_file(&path) + .await + .with_context(|| format!("remove file {}", path.display())) + }) + .collect(); + + try_join_all(remove_futures).await?; + + // Recursively clean subdirectories + let subdir_futures: Vec<_> = subdirs + .iter() + .map(|subdir| clean_dir_recursive(subdir, ext)) + .collect(); + + try_join_all(subdir_futures).await?; + + // Remove empty directories + for subdir in subdirs { + let mut entries = fs::read_dir(&subdir).await?; + if entries.next_entry().await?.is_none() { + fs::remove_dir(&subdir) + .await + .with_context(|| format!("remove empty dir {}", subdir.display()))?; + } + } + + Ok(()) +} + fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { // Sanitize file name: replace spaces with underscores let mut out = root.to_path_buf(); @@ -137,16 +243,16 @@ fn sanitize_filename(name: &str) -> String { .collect::() } -fn load_models_recursive(base: &Path) -> Result> { +async fn load_models_recursive(base: &Path) -> Result> { let mut out = Vec::new(); if !base.exists() { return Ok(out); } - walk_models(base, base, &mut out)?; + walk_models(base, base, &mut out).await?; Ok(out) } -fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { +async fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { // Only needed for SeaORM (Rust) exports to wire modules. // Strip extension and ".vespertide" suffix from filename let path_without_ext = rel_path.with_extension(""); @@ -178,10 +284,10 @@ fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { if let Some(parent) = mod_path.parent() && !parent.exists() { - fs::create_dir_all(parent)?; + fs::create_dir_all(parent).await?; } let mut content = if mod_path.exists() { - fs::read_to_string(&mod_path)? + fs::read_to_string(&mod_path).await? } else { String::new() }; @@ -192,18 +298,26 @@ fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { } content.push_str(&decl); content.push('\n'); - fs::write(mod_path, content)?; + fs::write(mod_path, content).await?; } } Ok(()) } -fn walk_models(root: &Path, current: &Path, acc: &mut Vec<(TableDef, PathBuf)>) -> Result<()> { - for entry in fs::read_dir(current).with_context(|| format!("read {}", current.display()))? { - let entry = entry?; +#[async_recursion::async_recursion] +async fn walk_models( + root: &Path, + current: &Path, + acc: &mut Vec<(TableDef, PathBuf)>, +) -> Result<()> { + let mut entries = fs::read_dir(current) + .await + .with_context(|| format!("read {}", current.display()))?; + + while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.is_dir() { - walk_models(root, &path, acc)?; + walk_models(root, &path, acc).await?; continue; } let ext = path.extension().and_then(|s| s.to_str()); @@ -211,6 +325,7 @@ fn walk_models(root: &Path, current: &Path, acc: &mut Vec<(TableDef, PathBuf)>) continue; } let content = fs::read_to_string(&path) + .await .with_context(|| format!("read model file: {}", path.display()))?; let table: TableDef = if ext == Some("json") { serde_json::from_str(&content) @@ -230,7 +345,7 @@ mod tests { use super::*; use rstest::rstest; use serial_test::serial; - use std::fs; + use std::fs as std_fs; use tempfile::tempdir; use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; @@ -255,14 +370,14 @@ mod tests { fn write_config() { let cfg = VespertideConfig::default(); let text = serde_json::to_string_pretty(&cfg).unwrap(); - fs::write("vespertide.json", text).unwrap(); + std_fs::write("vespertide.json", text).unwrap(); } fn write_model(path: &Path, table: &TableDef) { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); + std_fs::create_dir_all(parent).unwrap(); } - fs::write(path, serde_json::to_string_pretty(table).unwrap()).unwrap(); + std_fs::write(path, serde_json::to_string_pretty(table).unwrap()).unwrap(); } fn sample_table(name: &str) -> TableDef { @@ -287,9 +402,9 @@ mod tests { } } - #[test] + #[tokio::test] #[serial] - fn export_writes_seaorm_files_to_default_dir() { + async fn export_writes_seaorm_files_to_default_dir() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); @@ -297,23 +412,23 @@ mod tests { let model = sample_table("users"); write_model(Path::new("models/users.json"), &model); - cmd_export(OrmArg::Seaorm, None).unwrap(); + cmd_export(OrmArg::Seaorm, None).await.unwrap(); let out = PathBuf::from("src/models/users.rs"); assert!(out.exists()); - let content = fs::read_to_string(out).unwrap(); + let content = std_fs::read_to_string(out).unwrap(); assert!(content.contains("#[sea_orm(table_name = \"users\")]")); // mod.rs wiring at root let root_mod = PathBuf::from("src/models/mod.rs"); assert!(root_mod.exists()); - let root_mod_content = fs::read_to_string(root_mod).unwrap(); + let root_mod_content = std_fs::read_to_string(root_mod).unwrap(); assert!(root_mod_content.contains("pub mod users;")); } - #[test] + #[tokio::test] #[serial] - fn export_respects_custom_output_dir() { + async fn export_respects_custom_output_dir() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); @@ -322,11 +437,13 @@ mod tests { write_model(Path::new("models/blog/posts.json"), &model); let custom = PathBuf::from("out_dir"); - cmd_export(OrmArg::Seaorm, Some(custom.clone())).unwrap(); + cmd_export(OrmArg::Seaorm, Some(custom.clone())) + .await + .unwrap(); let out = custom.join("blog/posts.rs"); assert!(out.exists()); - let content = fs::read_to_string(out).unwrap(); + let content = std_fs::read_to_string(out).unwrap(); assert!(content.contains("#[sea_orm(table_name = \"posts\")]")); // mod.rs wiring @@ -334,15 +451,15 @@ mod tests { let blog_mod = custom.join("blog/mod.rs"); assert!(root_mod.exists()); assert!(blog_mod.exists()); - let root_mod_content = fs::read_to_string(root_mod).unwrap(); - let blog_mod_content = fs::read_to_string(blog_mod).unwrap(); + let root_mod_content = std_fs::read_to_string(root_mod).unwrap(); + let blog_mod_content = std_fs::read_to_string(blog_mod).unwrap(); assert!(root_mod_content.contains("pub mod blog;")); assert!(blog_mod_content.contains("pub mod posts;")); } - #[test] + #[tokio::test] #[serial] - fn export_with_sqlalchemy_sets_py_extension() { + async fn export_with_sqlalchemy_sets_py_extension() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); @@ -350,17 +467,17 @@ mod tests { let model = sample_table("items"); write_model(Path::new("models/items.json"), &model); - cmd_export(OrmArg::Sqlalchemy, None).unwrap(); + cmd_export(OrmArg::Sqlalchemy, None).await.unwrap(); let out = PathBuf::from("src/models/items.py"); assert!(out.exists()); - let content = fs::read_to_string(out).unwrap(); + let content = std_fs::read_to_string(out).unwrap(); assert!(content.contains("items")); } - #[test] + #[tokio::test] #[serial] - fn export_with_sqlmodel_sets_py_extension() { + async fn export_with_sqlmodel_sets_py_extension() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); @@ -368,68 +485,70 @@ mod tests { let model = sample_table("orders"); write_model(Path::new("models/orders.json"), &model); - cmd_export(OrmArg::Sqlmodel, None).unwrap(); + cmd_export(OrmArg::Sqlmodel, None).await.unwrap(); let out = PathBuf::from("src/models/orders.py"); assert!(out.exists()); - let content = fs::read_to_string(out).unwrap(); + let content = std_fs::read_to_string(out).unwrap(); assert!(content.contains("orders")); } - #[test] + #[tokio::test] #[serial] - fn load_models_recursive_returns_empty_when_absent() { + async fn load_models_recursive_returns_empty_when_absent() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - let models = load_models_recursive(Path::new("no_models")).unwrap(); + let models = load_models_recursive(Path::new("no_models")).await.unwrap(); assert!(models.is_empty()); } - #[test] + #[tokio::test] #[serial] - fn load_models_recursive_ignores_non_model_files() { + async fn load_models_recursive_ignores_non_model_files() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); - fs::create_dir_all("models").unwrap(); - fs::write("models/ignore.txt", "hello").unwrap(); + std_fs::create_dir_all("models").unwrap(); + std_fs::write("models/ignore.txt", "hello").unwrap(); write_model(Path::new("models/valid.json"), &sample_table("valid")); - let models = load_models_recursive(Path::new("models")).unwrap(); + let models = load_models_recursive(Path::new("models")).await.unwrap(); assert_eq!(models.len(), 1); assert_eq!(models[0].0.name, "valid"); } - #[test] + #[tokio::test] #[serial] - fn load_models_recursive_parses_yaml_branch() { + async fn load_models_recursive_parses_yaml_branch() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); write_config(); - fs::create_dir_all("models").unwrap(); + std_fs::create_dir_all("models").unwrap(); let table = sample_table("yaml_table"); let yaml = serde_yaml::to_string(&table).unwrap(); - fs::write("models/yaml_table.yaml", yaml).unwrap(); + std_fs::write("models/yaml_table.yaml", yaml).unwrap(); - let models = load_models_recursive(Path::new("models")).unwrap(); + let models = load_models_recursive(Path::new("models")).await.unwrap(); assert_eq!(models.len(), 1); assert_eq!(models[0].0.name, "yaml_table"); } - #[test] + #[tokio::test] #[serial] - fn ensure_mod_chain_adds_to_existing_file_without_trailing_newline() { + async fn ensure_mod_chain_adds_to_existing_file_without_trailing_newline() { let tmp = tempdir().unwrap(); let root = tmp.path().join("src/models"); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("mod.rs"), "pub mod existing;").unwrap(); + std_fs::create_dir_all(&root).unwrap(); + std_fs::write(root.join("mod.rs"), "pub mod existing;").unwrap(); - ensure_mod_chain(&root, Path::new("blog/posts.rs")).unwrap(); + ensure_mod_chain(&root, Path::new("blog/posts.rs")) + .await + .unwrap(); - let root_mod = fs::read_to_string(root.join("mod.rs")).unwrap(); - let blog_mod = fs::read_to_string(root.join("blog/mod.rs")).unwrap(); + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); + let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); assert!(root_mod.contains("pub mod existing;")); assert!(root_mod.contains("pub mod blog;")); assert!(blog_mod.contains("pub mod posts;")); @@ -437,13 +556,13 @@ mod tests { assert!(root_mod.ends_with('\n')); } - #[test] - fn ensure_mod_chain_no_components_is_noop() { + #[tokio::test] + async fn ensure_mod_chain_no_components_is_noop() { let tmp = tempdir().unwrap(); let root = tmp.path().join("src/models"); - fs::create_dir_all(&root).unwrap(); + std_fs::create_dir_all(&root).unwrap(); // empty path should not error - assert!(ensure_mod_chain(&root, Path::new("")).is_ok()); + assert!(ensure_mod_chain(&root, Path::new("")).await.is_ok()); } #[test] @@ -545,27 +664,146 @@ mod tests { assert_eq!(out4, Path::new("src/models/item.rs")); } - #[test] + #[tokio::test] #[serial] - fn ensure_mod_chain_strips_vespertide_suffix() { + async fn ensure_mod_chain_strips_vespertide_suffix() { let tmp = tempdir().unwrap(); let root = tmp.path().join("src/models"); - fs::create_dir_all(&root).unwrap(); + std_fs::create_dir_all(&root).unwrap(); // File with .vespertide suffix should produce mod declaration without it - ensure_mod_chain(&root, Path::new("user.vespertide.json")).unwrap(); + ensure_mod_chain(&root, Path::new("user.vespertide.json")) + .await + .unwrap(); - let root_mod = fs::read_to_string(root.join("mod.rs")).unwrap(); + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); // Should be "pub mod user;" not "pub mod user_vespertide;" assert!(root_mod.contains("pub mod user;")); assert!(!root_mod.contains("user_vespertide")); // Nested path with .vespertide suffix - ensure_mod_chain(&root, Path::new("blog/post.vespertide.json")).unwrap(); - let root_mod = fs::read_to_string(root.join("mod.rs")).unwrap(); - let blog_mod = fs::read_to_string(root.join("blog/mod.rs")).unwrap(); + ensure_mod_chain(&root, Path::new("blog/post.vespertide.json")) + .await + .unwrap(); + let root_mod = std_fs::read_to_string(root.join("mod.rs")).unwrap(); + let blog_mod = std_fs::read_to_string(root.join("blog/mod.rs")).unwrap(); assert!(root_mod.contains("pub mod blog;")); assert!(blog_mod.contains("pub mod post;")); assert!(!blog_mod.contains("post_vespertide")); } + + #[tokio::test] + async fn clean_export_dir_removes_rs_files_for_seaorm() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + // Create some .rs files that should be cleaned + std_fs::write(root.join("old_model.rs"), "// old rust file").unwrap(); + std_fs::write(root.join("another.rs"), "// another rust file").unwrap(); + // Create a non-.rs file that should NOT be cleaned + std_fs::write(root.join("keep.txt"), "keep this").unwrap(); + + clean_export_dir(&root, Orm::SeaOrm).await.unwrap(); + + // .rs files should be gone + assert!(!root.join("old_model.rs").exists()); + assert!(!root.join("another.rs").exists()); + // .txt file should remain + assert!(root.join("keep.txt").exists()); + } + + #[tokio::test] + async fn clean_export_dir_removes_py_files_for_sqlalchemy() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + // Create some .py files that should be cleaned + std_fs::write(root.join("old_model.py"), "# old python file").unwrap(); + // Create a .rs file that should NOT be cleaned + std_fs::write(root.join("keep.rs"), "// keep this").unwrap(); + + clean_export_dir(&root, Orm::SqlAlchemy).await.unwrap(); + + // .py files should be gone + assert!(!root.join("old_model.py").exists()); + // .rs file should remain + assert!(root.join("keep.rs").exists()); + } + + #[tokio::test] + async fn clean_export_dir_removes_py_files_for_sqlmodel() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + std_fs::create_dir_all(&root).unwrap(); + + std_fs::write(root.join("model.py"), "# python file").unwrap(); + + clean_export_dir(&root, Orm::SqlModel).await.unwrap(); + + assert!(!root.join("model.py").exists()); + } + + #[tokio::test] + async fn clean_export_dir_handles_missing_directory() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("nonexistent_dir"); + + // Should not error on missing directory + let result = clean_export_dir(&root, Orm::SeaOrm).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn clean_dir_recursive_cleans_subdirectories() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + let subdir = root.join("nested"); + std_fs::create_dir_all(&subdir).unwrap(); + + // Create files in root and subdirectory + std_fs::write(root.join("root.rs"), "// root").unwrap(); + std_fs::write(subdir.join("nested.rs"), "// nested").unwrap(); + std_fs::write(subdir.join("keep.txt"), "keep").unwrap(); + + clean_dir_recursive(&root, "rs").await.unwrap(); + + // .rs files should be gone + assert!(!root.join("root.rs").exists()); + assert!(!subdir.join("nested.rs").exists()); + // .txt file should remain + assert!(subdir.join("keep.txt").exists()); + // subdir should still exist (has .txt file) + assert!(subdir.exists()); + } + + #[tokio::test] + async fn clean_dir_recursive_removes_empty_subdirectories() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("export_dir"); + let subdir = root.join("empty_after_clean"); + std_fs::create_dir_all(&subdir).unwrap(); + + // Create only .rs files in subdirectory + std_fs::write(subdir.join("only.rs"), "// only").unwrap(); + + clean_dir_recursive(&root, "rs").await.unwrap(); + + // .rs file should be gone + assert!(!subdir.join("only.rs").exists()); + // Empty subdirectory should be removed + assert!(!subdir.exists()); + } + + #[tokio::test] + async fn clean_dir_recursive_handles_non_directory() { + let tmp = tempdir().unwrap(); + let file_path = tmp.path().join("not_a_dir.txt"); + std_fs::write(&file_path, "content").unwrap(); + + // Should not error when called on a file instead of directory + let result = clean_dir_recursive(&file_path, "rs").await; + assert!(result.is_ok()); + } } diff --git a/crates/vespertide-cli/src/commands/init.rs b/crates/vespertide-cli/src/commands/init.rs index a8422da..75f7bd5 100644 --- a/crates/vespertide-cli/src/commands/init.rs +++ b/crates/vespertide-cli/src/commands/init.rs @@ -1,10 +1,11 @@ -use std::{fs, path::PathBuf}; +use std::path::PathBuf; use anyhow::{Context, Result, bail}; use colored::Colorize; +use tokio::fs; use vespertide_config::VespertideConfig; -pub fn cmd_init() -> Result<()> { +pub async fn cmd_init() -> Result<()> { let path = PathBuf::from("vespertide.json"); if path.exists() { bail!("vespertide.json already exists"); @@ -12,7 +13,9 @@ pub fn cmd_init() -> Result<()> { let config = VespertideConfig::default(); let json = serde_json::to_string_pretty(&config).context("serialize default config")?; - fs::write(&path, json).context("write vespertide.json")?; + fs::write(&path, json) + .await + .context("write vespertide.json")?; println!( "{} {}", "created".bright_green().bold(), @@ -45,24 +48,24 @@ mod tests { } } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_init_creates_config() { + async fn cmd_init_creates_config() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - cmd_init().unwrap(); + cmd_init().await.unwrap(); assert!(PathBuf::from("vespertide.json").exists()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_init_fails_when_exists() { + async fn cmd_init_fails_when_exists() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - cmd_init().unwrap(); - let err = cmd_init().unwrap_err(); + cmd_init().await.unwrap(); + let err = cmd_init().await.unwrap_err(); assert!(err.to_string().contains("already exists")); } } diff --git a/crates/vespertide-cli/src/commands/log.rs b/crates/vespertide-cli/src/commands/log.rs index f877405..f935940 100644 --- a/crates/vespertide-cli/src/commands/log.rs +++ b/crates/vespertide-cli/src/commands/log.rs @@ -6,7 +6,7 @@ use vespertide_query::{DatabaseBackend, build_plan_queries}; use crate::utils::load_migrations; -pub fn cmd_log(backend: DatabaseBackend) -> Result<()> { +pub async fn cmd_log(backend: DatabaseBackend) -> Result<()> { let config = load_config()?; let plans = load_migrations(&config)?; @@ -149,9 +149,9 @@ mod tests { fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_with_single_migration_postgres() { + async fn cmd_log_with_single_migration_postgres() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -159,13 +159,13 @@ mod tests { write_config(&cfg); write_migration(&cfg); - let result = cmd_log(DatabaseBackend::Postgres); + let result = cmd_log(DatabaseBackend::Postgres).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_with_single_migration_mysql() { + async fn cmd_log_with_single_migration_mysql() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -173,13 +173,13 @@ mod tests { write_config(&cfg); write_migration(&cfg); - let result = cmd_log(DatabaseBackend::MySql); + let result = cmd_log(DatabaseBackend::MySql).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_with_single_migration_sqlite() { + async fn cmd_log_with_single_migration_sqlite() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -187,13 +187,13 @@ mod tests { write_config(&cfg); write_migration(&cfg); - let result = cmd_log(DatabaseBackend::Sqlite); + let result = cmd_log(DatabaseBackend::Sqlite).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_no_migrations_postgres() { + async fn cmd_log_no_migrations_postgres() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -201,13 +201,13 @@ mod tests { write_config(&cfg); fs::create_dir_all(cfg.migrations_dir()).unwrap(); - let result = cmd_log(DatabaseBackend::Postgres); + let result = cmd_log(DatabaseBackend::Postgres).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_no_migrations_mysql() { + async fn cmd_log_no_migrations_mysql() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -215,13 +215,13 @@ mod tests { write_config(&cfg); fs::create_dir_all(cfg.migrations_dir()).unwrap(); - let result = cmd_log(DatabaseBackend::MySql); + let result = cmd_log(DatabaseBackend::MySql).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_no_migrations_sqlite() { + async fn cmd_log_no_migrations_sqlite() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -229,13 +229,13 @@ mod tests { write_config(&cfg); fs::create_dir_all(cfg.migrations_dir()).unwrap(); - let result = cmd_log(DatabaseBackend::Sqlite); + let result = cmd_log(DatabaseBackend::Sqlite).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_log_with_multiple_sql_statements() { + async fn cmd_log_with_multiple_sql_statements() { use vespertide_core::schema::primary_key::PrimaryKeySyntax; use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; @@ -279,7 +279,7 @@ mod tests { // SQLite backend will generate multiple SQL statements for ModifyColumnType (table recreation) // This exercises line 84 where sql_statements.len() > 1 - let result = cmd_log(DatabaseBackend::Sqlite); + let result = cmd_log(DatabaseBackend::Sqlite).await; assert!(result.is_ok()); } } diff --git a/crates/vespertide-cli/src/commands/new.rs b/crates/vespertide-cli/src/commands/new.rs index 0ee17b7..4dc5899 100644 --- a/crates/vespertide-cli/src/commands/new.rs +++ b/crates/vespertide-cli/src/commands/new.rs @@ -1,19 +1,22 @@ -use std::fs; +use std::path::Path; use anyhow::{Context, Result, bail}; use colored::Colorize; use serde_json::Value; +use tokio::fs; use vespertide_core::TableDef; use crate::utils::load_config; use vespertide_config::FileFormat; -pub fn cmd_new(name: String, format: Option) -> Result<()> { +pub async fn cmd_new(name: String, format: Option) -> Result<()> { let config = load_config()?; let format = format.unwrap_or_else(|| config.model_format()); let dir = config.models_dir(); if !dir.exists() { - fs::create_dir_all(dir).context("create models directory")?; + fs::create_dir_all(dir) + .await + .context("create models directory")?; } let ext = match format { @@ -36,8 +39,8 @@ pub fn cmd_new(name: String, format: Option) -> Result<()> { }; match format { - FileFormat::Json => write_json_with_schema(&path, &table, &schema_url)?, - FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &table, &schema_url)?, + FileFormat::Json => write_json_with_schema(&path, &table, &schema_url).await?, + FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &table, &schema_url).await?, } println!( @@ -62,21 +65,19 @@ fn schema_url_for(format: FileFormat) -> String { } } -fn write_json_with_schema( - path: &std::path::Path, - table: &TableDef, - schema_url: &str, -) -> Result<()> { +async fn write_json_with_schema(path: &Path, table: &TableDef, schema_url: &str) -> Result<()> { let mut value = serde_json::to_value(table).context("serialize table to json")?; if let Value::Object(ref mut map) = value { map.insert("$schema".to_string(), Value::String(schema_url.to_string())); } let text = serde_json::to_string_pretty(&value).context("stringify json with schema")?; - fs::write(path, text).with_context(|| format!("write file: {}", path.display()))?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; Ok(()) } -fn write_yaml(path: &std::path::Path, table: &TableDef, schema_url: &str) -> Result<()> { +async fn write_yaml(path: &Path, table: &TableDef, schema_url: &str) -> Result<()> { let mut value = serde_yaml::to_value(table).context("serialize table to yaml value")?; if let serde_yaml::Value::Mapping(ref mut map) = value { map.insert( @@ -85,7 +86,9 @@ fn write_yaml(path: &std::path::Path, table: &TableDef, schema_url: &str) -> Res ); } let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; - fs::write(path, text).with_context(|| format!("write file: {}", path.display()))?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; Ok(()) } @@ -93,15 +96,17 @@ fn write_yaml(path: &std::path::Path, table: &TableDef, schema_url: &str) -> Res mod tests { use super::*; use std::env; + use std::fs as std_fs; + use std::path::PathBuf; use tempfile::tempdir; use vespertide_config::VespertideConfig; struct CwdGuard { - original: std::path::PathBuf, + original: PathBuf, } impl CwdGuard { - fn new(dir: &std::path::Path) -> Self { + fn new(dir: &Path) -> Self { let original = env::current_dir().unwrap(); env::set_current_dir(dir).unwrap(); Self { original } @@ -120,24 +125,24 @@ mod tests { ..VespertideConfig::default() }; let text = serde_json::to_string_pretty(&cfg).unwrap(); - std::fs::write("vespertide.json", text).unwrap(); + std_fs::write("vespertide.json", text).unwrap(); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_new_creates_json_with_schema() { + async fn cmd_new_creates_json_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); let expected_schema = schema_url_for(FileFormat::Json); write_config(FileFormat::Json); - cmd_new("users".into(), None).unwrap(); + cmd_new("users".into(), None).await.unwrap(); let cfg = VespertideConfig::default(); let path = cfg.models_dir().join("users.vespertide.json"); assert!(path.exists()); - let text = fs::read_to_string(path).unwrap(); + let text = std_fs::read_to_string(path).unwrap(); let value: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!( value.get("$schema"), @@ -145,15 +150,15 @@ mod tests { ); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_new_creates_yaml_with_schema() { + async fn cmd_new_creates_yaml_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); let expected_schema = schema_url_for(FileFormat::Yaml); write_config(FileFormat::Yaml); - cmd_new("orders".into(), None).unwrap(); + cmd_new("orders".into(), None).await.unwrap(); let cfg = VespertideConfig { model_format: FileFormat::Yaml, @@ -162,7 +167,7 @@ mod tests { let path = cfg.models_dir().join("orders.vespertide.yaml"); assert!(path.exists()); - let text = fs::read_to_string(path).unwrap(); + let text = std_fs::read_to_string(path).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); let schema = value .as_mapping() @@ -171,15 +176,15 @@ mod tests { assert_eq!(schema, Some(expected_schema.as_str())); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_new_creates_yml_with_schema() { + async fn cmd_new_creates_yml_with_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); let expected_schema = schema_url_for(FileFormat::Yml); write_config(FileFormat::Yml); - cmd_new("products".into(), None).unwrap(); + cmd_new("products".into(), None).await.unwrap(); let cfg = VespertideConfig { model_format: FileFormat::Yml, @@ -188,7 +193,7 @@ mod tests { let path = cfg.models_dir().join("products.vespertide.yml"); assert!(path.exists()); - let text = fs::read_to_string(path).unwrap(); + let text = std_fs::read_to_string(path).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); let schema = value .as_mapping() @@ -197,19 +202,19 @@ mod tests { assert_eq!(schema, Some(expected_schema.as_str())); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_new_fails_if_model_file_exists() { + async fn cmd_new_fails_if_model_file_exists() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(tmp.path()); write_config(FileFormat::Json); let cfg = VespertideConfig::default(); - std::fs::create_dir_all(cfg.models_dir()).unwrap(); + std_fs::create_dir_all(cfg.models_dir()).unwrap(); let path = cfg.models_dir().join("users.vespertide.json"); - std::fs::write(&path, "{}").unwrap(); + std_fs::write(&path, "{}").unwrap(); - let err = cmd_new("users".into(), None).unwrap_err(); + let err = cmd_new("users".into(), None).await.unwrap_err(); let msg = err.to_string(); assert!(msg.contains("model file already exists")); assert!(msg.contains("users.vespertide.json")); diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index b262ae5..6804e68 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; -use std::fs; +use std::path::Path; use anyhow::{Context, Result}; use chrono::Utc; use colored::Colorize; use dialoguer::{Input, Select}; use serde_json::Value; +use tokio::fs; use vespertide_config::FileFormat; use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; use vespertide_planner::{find_missing_fill_with, plan_next_migration, schema_from_plans}; @@ -262,7 +263,7 @@ fn check_non_nullable_fk_add_columns(plan: &MigrationPlan) -> Result<()> { Ok(()) } -pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> { +pub async fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; let applied_plans = load_migrations(&config)?; @@ -310,7 +311,9 @@ pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> let migrations_dir = config.migrations_dir(); if !migrations_dir.exists() { - fs::create_dir_all(migrations_dir).context("create migrations directory")?; + fs::create_dir_all(&migrations_dir) + .await + .context("create migrations directory")?; } let format = config.migration_format(); @@ -324,8 +327,8 @@ pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> let schema_url = schema_url_for(format); match format { - FileFormat::Json => write_json_with_schema(&path, &plan, &schema_url)?, - FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &plan, &schema_url)?, + FileFormat::Json => write_json_with_schema(&path, &plan, &schema_url).await?, + FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &plan, &schema_url).await?, } println!( @@ -364,21 +367,19 @@ fn schema_url_for(format: FileFormat) -> String { } } -fn write_json_with_schema( - path: &std::path::Path, - plan: &MigrationPlan, - schema_url: &str, -) -> Result<()> { +async fn write_json_with_schema(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { let mut value = serde_json::to_value(plan).context("serialize migration plan to json")?; if let Value::Object(ref mut map) = value { map.insert("$schema".to_string(), Value::String(schema_url.to_string())); } let text = serde_json::to_string_pretty(&value).context("stringify json with schema")?; - fs::write(path, text).with_context(|| format!("write file: {}", path.display()))?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; Ok(()) } -fn write_yaml(path: &std::path::Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { +async fn write_yaml(path: &Path, plan: &MigrationPlan, schema_url: &str) -> Result<()> { let mut value = serde_yaml::to_value(plan).context("serialize migration plan to yaml value")?; if let serde_yaml::Value::Mapping(ref mut map) = value { map.insert( @@ -387,14 +388,16 @@ fn write_yaml(path: &std::path::Path, plan: &MigrationPlan, schema_url: &str) -> ); } let text = serde_yaml::to_string(&value).context("serialize yaml with schema")?; - fs::write(path, text).with_context(|| format!("write file: {}", path.display()))?; + fs::write(path, text) + .await + .with_context(|| format!("write file: {}", path.display()))?; Ok(()) } #[cfg(test)] mod tests { use super::*; - use std::{env, fs, path::PathBuf}; + use std::{env, fs as std_fs, path::PathBuf}; use tempfile::tempdir; use vespertide_config::{FileFormat, VespertideConfig}; use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint, TableDef}; @@ -427,13 +430,13 @@ mod tests { cfg.migration_format = f; } let text = serde_json::to_string_pretty(&cfg).unwrap(); - fs::write("vespertide.json", text).unwrap(); + std_fs::write("vespertide.json", text).unwrap(); cfg } fn write_model(name: &str) { let models_dir = PathBuf::from("models"); - fs::create_dir_all(&models_dir).unwrap(); + std_fs::create_dir_all(&models_dir).unwrap(); let table = TableDef { name: name.to_string(), description: None, @@ -454,41 +457,41 @@ mod tests { }], }; let path = models_dir.join(format!("{name}.json")); - fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); + std_fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_revision_writes_migration() { + async fn cmd_revision_writes_migration() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let cfg = write_config(); write_model("users"); - fs::create_dir_all(cfg.migrations_dir()).unwrap(); + std_fs::create_dir_all(cfg.migrations_dir()).unwrap(); - cmd_revision("init".into(), vec![]).unwrap(); + cmd_revision("init".into(), vec![]).await.unwrap(); - let entries: Vec<_> = fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); assert!(!entries.is_empty()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_revision_no_changes_short_circuits() { + async fn cmd_revision_no_changes_short_circuits() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let cfg = write_config(); // no models, no migrations -> plan with no actions -> early return - assert!(cmd_revision("noop".into(), vec![]).is_ok()); + assert!(cmd_revision("noop".into(), vec![]).await.is_ok()); // migrations dir should not be created assert!(!cfg.migrations_dir().exists()); } - #[test] + #[tokio::test] #[serial_test::serial] - fn cmd_revision_writes_yaml_when_configured() { + async fn cmd_revision_writes_yaml_when_configured() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -496,12 +499,12 @@ mod tests { write_model("users"); // ensure migrations dir absent to exercise create_dir_all branch if cfg.migrations_dir().exists() { - fs::remove_dir_all(cfg.migrations_dir()).unwrap(); + std_fs::remove_dir_all(cfg.migrations_dir()).unwrap(); } - cmd_revision("yaml".into(), vec![]).unwrap(); + cmd_revision("yaml".into(), vec![]).await.unwrap(); - let entries: Vec<_> = fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + let entries: Vec<_> = std_fs::read_dir(cfg.migrations_dir()).unwrap().collect(); assert!(!entries.is_empty()); let has_yaml = entries.iter().any(|e| { e.as_ref() diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index ef406af..1889ec6 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -5,7 +5,7 @@ use vespertide_query::{DatabaseBackend, build_plan_queries}; use crate::utils::{load_config, load_migrations, load_models}; -pub fn cmd_sql(backend: DatabaseBackend) -> Result<()> { +pub async fn cmd_sql(backend: DatabaseBackend) -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; let applied_plans = load_migrations(&config)?; @@ -178,48 +178,48 @@ mod tests { fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); } - #[test] + #[tokio::test] #[serial] - fn cmd_sql_emits_queries_postgres() { + async fn cmd_sql_emits_queries_postgres() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let _cfg = write_config(); write_model("users"); - let result = cmd_sql(DatabaseBackend::Postgres); + let result = cmd_sql(DatabaseBackend::Postgres).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn cmd_sql_emits_queries_mysql() { + async fn cmd_sql_emits_queries_mysql() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let _cfg = write_config(); write_model("users"); - let result = cmd_sql(DatabaseBackend::MySql); + let result = cmd_sql(DatabaseBackend::MySql).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn cmd_sql_emits_queries_sqlite() { + async fn cmd_sql_emits_queries_sqlite() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let _cfg = write_config(); write_model("users"); - let result = cmd_sql(DatabaseBackend::Sqlite); + let result = cmd_sql(DatabaseBackend::Sqlite).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn cmd_sql_no_changes_postgres() { + async fn cmd_sql_no_changes_postgres() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -253,13 +253,13 @@ mod tests { let path = cfg.migrations_dir().join("0001_init.json"); fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); - let result = cmd_sql(DatabaseBackend::Postgres); + let result = cmd_sql(DatabaseBackend::Postgres).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn cmd_sql_no_changes_mysql() { + async fn cmd_sql_no_changes_mysql() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -293,13 +293,13 @@ mod tests { let path = cfg.migrations_dir().join("0001_init.json"); fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); - let result = cmd_sql(DatabaseBackend::MySql); + let result = cmd_sql(DatabaseBackend::MySql).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn cmd_sql_no_changes_sqlite() { + async fn cmd_sql_no_changes_sqlite() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -333,13 +333,13 @@ mod tests { let path = cfg.migrations_dir().join("0001_init.json"); fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); - let result = cmd_sql(DatabaseBackend::Sqlite); + let result = cmd_sql(DatabaseBackend::Sqlite).await; assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn emit_sql_prints_created_at_and_comment_postgres() { + async fn emit_sql_prints_created_at_and_comment_postgres() { let plan = MigrationPlan { comment: Some("with comment".into()), created_at: Some("2024-01-02T00:00:00Z".into()), @@ -353,9 +353,9 @@ mod tests { assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn emit_sql_prints_created_at_and_comment_mysql() { + async fn emit_sql_prints_created_at_and_comment_mysql() { let plan = MigrationPlan { comment: Some("with comment".into()), created_at: Some("2024-01-02T00:00:00Z".into()), @@ -369,9 +369,9 @@ mod tests { assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn emit_sql_prints_created_at_and_comment_sqlite() { + async fn emit_sql_prints_created_at_and_comment_sqlite() { let plan = MigrationPlan { comment: Some("with comment".into()), created_at: Some("2024-01-02T00:00:00Z".into()), @@ -385,9 +385,9 @@ mod tests { assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn emit_sql_multiple_queries() { + async fn emit_sql_multiple_queries() { let plan = MigrationPlan { comment: None, created_at: None, @@ -422,9 +422,9 @@ mod tests { assert!(result.is_ok()); } - #[test] + #[tokio::test] #[serial] - fn emit_sql_multiple_queries_per_action() { + async fn emit_sql_multiple_queries_per_action() { // Test case where a single action generates multiple queries (e.g., SQLite constraint addition) // This should trigger the queries.len() > 1 branch (line 89) let tmp = tempdir().unwrap(); diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index 33bfd39..627771b 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -5,7 +5,7 @@ use vespertide_planner::schema_from_plans; use crate::utils::{load_config, load_migrations, load_models}; use std::collections::HashSet; -pub fn cmd_status() -> Result<()> { +pub async fn cmd_status() -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; let applied_plans = load_migrations(&config)?; @@ -248,9 +248,9 @@ mod tests { fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); } - #[test] + #[tokio::test] #[serial] - fn cmd_status_with_matching_schema() { + async fn cmd_status_with_matching_schema() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -258,36 +258,36 @@ mod tests { write_model("users"); write_migration(&cfg); - cmd_status().unwrap(); + cmd_status().await.unwrap(); } - #[test] + #[tokio::test] #[serial] - fn cmd_status_no_models_no_migrations_prints_message() { + async fn cmd_status_no_models_no_migrations_prints_message() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let cfg = write_config(); fs::create_dir_all(cfg.models_dir()).unwrap(); // empty models dir fs::create_dir_all(cfg.migrations_dir()).unwrap(); // empty migrations dir - cmd_status().unwrap(); + cmd_status().await.unwrap(); } - #[test] + #[tokio::test] #[serial] - fn cmd_status_models_no_migrations_prints_hint() { + async fn cmd_status_models_no_migrations_prints_hint() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); let cfg = write_config(); write_model("users"); fs::create_dir_all(cfg.migrations_dir()).unwrap(); - cmd_status().unwrap(); + cmd_status().await.unwrap(); } - #[test] + #[tokio::test] #[serial] - fn cmd_status_differs_prints_diff_hint() { + async fn cmd_status_differs_prints_diff_hint() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -297,12 +297,12 @@ mod tests { write_model("posts"); write_migration(&cfg); // baseline only has users - cmd_status().unwrap(); + cmd_status().await.unwrap(); } - #[test] + #[tokio::test] #[serial] - fn cmd_status_model_with_description() { + async fn cmd_status_model_with_description() { let tmp = tempdir().unwrap(); let _guard = CwdGuard::new(&tmp.path().to_path_buf()); @@ -333,6 +333,6 @@ mod tests { let path = cfg.models_dir().join("users.json"); fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); - cmd_status().unwrap(); + cmd_status().await.unwrap(); } } diff --git a/crates/vespertide-cli/src/main.rs b/crates/vespertide-cli/src/main.rs index a90d6fb..5a61f42 100644 --- a/crates/vespertide-cli/src/main.rs +++ b/crates/vespertide-cli/src/main.rs @@ -84,17 +84,18 @@ enum Commands { } #[cfg(not(tarpaulin_include))] -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Some(Commands::Diff) => cmd_diff(), - Some(Commands::Sql { backend }) => cmd_sql(backend.into()), - Some(Commands::Log { backend }) => cmd_log(backend.into()), - Some(Commands::New { name, format }) => cmd_new(name, format), - Some(Commands::Status) => cmd_status(), - Some(Commands::Revision { message, fill_with }) => cmd_revision(message, fill_with), - Some(Commands::Init) => cmd_init(), - Some(Commands::Export { orm, export_dir }) => cmd_export(orm, export_dir), + Some(Commands::Diff) => cmd_diff().await, + Some(Commands::Sql { backend }) => cmd_sql(backend.into()).await, + Some(Commands::Log { backend }) => cmd_log(backend.into()).await, + Some(Commands::New { name, format }) => cmd_new(name, format).await, + Some(Commands::Status) => cmd_status().await, + Some(Commands::Revision { message, fill_with }) => cmd_revision(message, fill_with).await, + Some(Commands::Init) => cmd_init().await, + Some(Commands::Export { orm, export_dir }) => cmd_export(orm, export_dir).await, None => { // No subcommand: show help and exit successfully. Cli::command().print_help()?; diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml index 006e1a2..fc86d4e 100644 --- a/crates/vespertide-exporter/Cargo.toml +++ b/crates/vespertide-exporter/Cargo.toml @@ -11,6 +11,7 @@ description = "Export vespertide table definitions into ORM-specific models" [dependencies] vespertide-core = { workspace = true } vespertide-config = { workspace = true } +vespertide-naming = { workspace = true } thiserror = "2" [dev-dependencies] diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 658b727..166b5ea 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -440,7 +440,35 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec let mut out = Vec::new(); let mut used = HashSet::new(); - // Group FKs by their target table to detect duplicates + // First, collect ALL target entities from both forward and reverse relations + // to detect when relation_enum is needed (same entity appears multiple times) + let mut all_target_entities: Vec = Vec::new(); + + // Collect forward relation targets (belongs_to) + for constraint in &table.constraints { + if let TableConstraint::ForeignKey { + ref_table, + ref_columns, + .. + } = constraint + { + let (resolved_table, _) = resolve_fk_target(ref_table, ref_columns, schema); + all_target_entities.push(resolved_table.to_string()); + } + } + + // Collect reverse relation targets (has_one/has_many) + let reverse_targets = collect_reverse_relation_targets(table, schema); + all_target_entities.extend(reverse_targets); + + // Count occurrences of each target entity + let mut entity_count: std::collections::HashMap = + std::collections::HashMap::new(); + for entity in &all_target_entities { + *entity_count.entry(entity.clone()).or_insert(0) += 1; + } + + // Group FKs by their target table to detect duplicates within forward relations let mut fk_by_table: std::collections::HashMap> = std::collections::HashMap::new(); for constraint in &table.constraints { @@ -458,6 +486,9 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec } } + // Track used relation_enum names across all relations + let mut used_relation_enums: HashSet = HashSet::new(); + // belongs_to relations (this table has FK to other tables) for constraint in &table.constraints { if let TableConstraint::ForeignKey { @@ -474,29 +505,40 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec let from = fk_attr_value(columns); let to = fk_attr_value(&resolved_columns); - // Check if there are multiple FKs to the same target table + // Check if there are multiple FKs to the same target table (within forward relations) let fks_to_this_table = fk_by_table .get(resolved_table) .map(|fks| fks.len()) .unwrap_or(0); + // Check if this target entity appears multiple times across ALL relations + let entity_appears_multiple_times = entity_count + .get(resolved_table) + .map(|c| *c > 1) + .unwrap_or(false); + // Smart field name inference from FK column names - // Try to use the FK column name (without _id suffix) as the field name - // If that doesn't work (conflicts), fall back to table name let field_base = if columns.len() == 1 { - // For single-column FKs, try to infer from column name infer_field_name_from_fk_column(&columns[0], resolved_table, &to) } else { - // For composite FKs, use table name sanitize_field_name(resolved_table) }; let field_name = unique_name(&field_base, &mut used); - // Generate relation_enum name if there are multiple FKs to the same table - let attr = if fks_to_this_table > 1 { - // Generate a unique relation enum name from the FK column(s) - let relation_enum_name = generate_relation_enum_name(columns); + // Generate relation_enum if: + // 1. Multiple FKs to same table within this table's forward relations, OR + // 2. This target entity appears in both forward and reverse relations + let needs_relation_enum = fks_to_this_table > 1 || entity_appears_multiple_times; + + let attr = if needs_relation_enum { + let base_relation_enum = generate_relation_enum_name(columns); + let relation_enum_name = if used_relation_enums.contains(&base_relation_enum) { + format!("{}{}", base_relation_enum, to_pascal_case(&table.name)) + } else { + base_relation_enum.clone() + }; + used_relation_enums.insert(relation_enum_name.clone()); format!( " #[sea_orm(belongs_to, relation_enum = \"{relation_enum_name}\", from = \"{from}\", to = \"{to}\")]" ) @@ -512,7 +554,13 @@ fn relation_field_defs_with_schema(table: &TableDef, schema: &[TableDef]) -> Vec } // has_one/has_many relations (other tables have FK to this table) - let reverse_relations = reverse_relation_field_defs(table, schema, &mut used); + let reverse_relations = reverse_relation_field_defs( + table, + schema, + &mut used, + &entity_count, + &mut used_relation_enums, + ); out.extend(reverse_relations); out @@ -572,15 +620,131 @@ fn infer_field_name_from_fk_column(fk_column: &str, table_name: &str, to: &str) } } +/// Information about a reverse relation to be generated. +struct ReverseRelation { + /// Target entity name (the table that has FK to current table) + target_entity: String, + /// Whether it's has_one (true) or has_many (false) + is_one_to_one: bool, + /// Base field name before uniquification + field_base: String, + /// Base relation_enum name (from FK columns) + base_relation_enum: String, + /// Source table name (for disambiguation) + source_table: String, + /// Whether the source table has multiple FKs to current table + has_multiple_fks: bool, + /// Optional via clause for M2M relations + via: Option, + /// Whether this is a M2M relation (through junction table) + is_m2m: bool, +} + +/// Collect target entities from reverse relations (for counting across all relations). +fn collect_reverse_relation_targets(table: &TableDef, schema: &[TableDef]) -> Vec { + let mut targets = Vec::new(); + + for other_table in schema { + if other_table.name == table.name { + continue; + } + + // Get PK columns for junction table detection + let other_pk = primary_key_columns(other_table); + + // Check if this is a junction table + if let Some(m2m_targets) = + collect_many_to_many_targets(table, other_table, &other_pk, schema) + { + targets.extend(m2m_targets); + continue; + } + + // Check for direct FK to this table + for constraint in &other_table.constraints { + if let TableConstraint::ForeignKey { ref_table, .. } = constraint + && ref_table == &table.name + { + targets.push(other_table.name.clone()); + } + } + } + + targets +} + +/// Collect target entities from a junction table for M2M relations. +fn collect_many_to_many_targets( + current_table: &TableDef, + junction_table: &TableDef, + junction_pk: &HashSet, + schema: &[TableDef], +) -> Option> { + if junction_pk.len() < 2 { + return None; + } + + let fks: Vec<_> = junction_table + .constraints + .iter() + .filter_map(|c| { + if let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + { + Some((columns.clone(), ref_table.clone())) + } else { + None + } + }) + .collect(); + + if fks.len() < 2 { + return None; + } + + let all_fk_cols_in_pk = fks + .iter() + .all(|(cols, _)| cols.iter().all(|c| junction_pk.contains(c))); + + if !all_fk_cols_in_pk { + return None; + } + + fks.iter() + .find(|(_, ref_table)| ref_table == ¤t_table.name)?; + + let mut targets = Vec::new(); + + // Junction table itself + targets.push(junction_table.name.clone()); + + // Target tables via M2M + for (_, ref_table) in &fks { + if ref_table == ¤t_table.name { + continue; + } + let target_exists = schema.iter().any(|t| &t.name == ref_table); + if target_exists { + targets.push(ref_table.clone()); + } + } + + Some(targets) +} + /// Generate reverse relation fields (has_one/has_many) for tables that reference this table. fn reverse_relation_field_defs( table: &TableDef, schema: &[TableDef], used: &mut HashSet, + entity_count: &std::collections::HashMap, + used_relation_enums: &mut HashSet, ) -> Vec { - let mut out = Vec::new(); + // First pass: collect all reverse relations + let mut relations: Vec = Vec::new(); - // First, count how many FKs from each table reference this table + // Count how many FKs from each table reference this table let mut fk_count_per_table: std::collections::HashMap = std::collections::HashMap::new(); for other_table in schema { @@ -598,7 +762,7 @@ fn reverse_relation_field_defs( } } - // Find all tables that have FK to this table + // Collect all relations from all tables for other_table in schema { if other_table.name == table.name { continue; @@ -610,9 +774,9 @@ fn reverse_relation_field_defs( // Check if this is a junction table (composite PK with multiple FKs) if let Some(m2m_relations) = - detect_many_to_many(table, other_table, &other_pk, schema, used) + collect_many_to_many_relations(table, other_table, &other_pk, schema) { - out.extend(m2m_relations); + relations.extend(m2m_relations); continue; } @@ -624,35 +788,25 @@ fn reverse_relation_field_defs( // Check if this FK references our table if ref_table == &table.name { // Determine if it's has_one or has_many - // has_one: FK columns exactly match the entire PK, or have UNIQUE constraint - // has_many: FK columns don't uniquely identify the row let is_one_to_one = if columns.len() == 1 { let col = &columns[0]; - // Single column FK: check if it's the entire PK (not just part of composite PK) - // or has a UNIQUE constraint let is_sole_pk = other_pk.len() == 1 && other_pk.contains(col); let is_unique = other_unique.contains(col); is_sole_pk || is_unique } else { - // Composite FK: check if FK columns exactly match the entire PK columns.len() == other_pk.len() && columns.iter().all(|c| other_pk.contains(c)) }; - let relation_type = if is_one_to_one { "has_one" } else { "has_many" }; - let rust_type = if is_one_to_one { "HasOne" } else { "HasMany" }; - - // Check if this table has multiple FKs to current table let has_multiple_fks = fk_count_per_table .get(&other_table.name) .map(|count| *count > 1) .unwrap_or(false); // Generate base field name - let base = if has_multiple_fks { - // Use relation_enum name to infer field name for multiple FKs - let relation_enum_name = generate_relation_enum_name(columns); - let lowercase_enum = to_snake_case(&relation_enum_name); + let base_relation_enum = generate_relation_enum_name(columns); + let field_base = if has_multiple_fks { + let lowercase_enum = to_snake_case(&base_relation_enum); if is_one_to_one { lowercase_enum } else { @@ -662,49 +816,102 @@ fn reverse_relation_field_defs( pluralize(&sanitize_field_name(&other_table.name)) ) } + } else if is_one_to_one { + sanitize_field_name(&other_table.name) } else { - // Default naming for single FK - if is_one_to_one { - sanitize_field_name(&other_table.name) - } else { - pluralize(&sanitize_field_name(&other_table.name)) - } - }; - let field_name = unique_name(&base, used); - - // Generate relation_enum name if there are multiple FKs to this table - let attr = if has_multiple_fks { - let relation_enum_name = generate_relation_enum_name(columns); - format!( - " #[sea_orm({relation_type}, relation_enum = \"{relation_enum_name}\", via_rel = \"{relation_enum_name}\")]" - ) - } else { - format!(" #[sea_orm({relation_type})]") + pluralize(&sanitize_field_name(&other_table.name)) }; - out.push(attr); - out.push(format!( - " pub {field_name}: {rust_type},", - other_table.name - )); + relations.push(ReverseRelation { + target_entity: other_table.name.clone(), + is_one_to_one, + field_base, + base_relation_enum, + source_table: other_table.name.clone(), + has_multiple_fks, + via: None, + is_m2m: false, + }); } } } } + // Second pass: generate output with relation_enum when needed + let mut out = Vec::new(); + + for rel in relations { + let relation_type = if rel.is_one_to_one { + "has_one" + } else { + "has_many" + }; + let rust_type = if rel.is_one_to_one { + "HasOne" + } else { + "HasMany" + }; + let field_name = unique_name(&rel.field_base, used); + + // Determine if we need relation_enum: + // 1. Multiple FKs from same source table, OR + // 2. Multiple relations targeting the same entity (across ALL relations including forward) + let needs_relation_enum = rel.has_multiple_fks + || entity_count + .get(&rel.target_entity) + .map(|c| *c > 1) + .unwrap_or(false); + + let attr = if needs_relation_enum { + // When multiple HasMany/HasOne target the same Entity, ALL need `via` + // - M2M relations: via = junction_table + // - Direct FK relations: via = source_table (the table with the FK) + let via_value = rel.via.as_ref().unwrap_or(&rel.source_table); + + let relation_enum_name = if rel.is_m2m { + // M2M: use {Target}Via{Junction} pattern directly + // e.g., "MediaViaUserMediaRole" + rel.base_relation_enum.clone() + } else { + // Direct: use via table name, fall back to FK-based on collision + let base_enum = to_pascal_case(via_value); + if used_relation_enums.contains(&base_enum) { + rel.base_relation_enum.clone() + } else { + base_enum + } + }; + used_relation_enums.insert(relation_enum_name.clone()); + + format!( + " #[sea_orm({relation_type}, relation_enum = \"{relation_enum_name}\", via = \"{via_value}\")]" + ) + } else if let Some(via) = &rel.via { + // No ambiguity - just via without relation_enum + format!(" #[sea_orm({relation_type}, via = \"{via}\")]") + } else { + format!(" #[sea_orm({relation_type})]") + }; + + out.push(attr); + out.push(format!( + " pub {field_name}: {rust_type},", + rel.target_entity + )); + } + out } -/// Detect if a table is a junction table for many-to-many relationship. +/// Collect many-to-many relations from a junction table. /// Returns Some(relations) if it's a junction table that links current table to other tables, /// or None if it's not a junction table. -fn detect_many_to_many( +fn collect_many_to_many_relations( current_table: &TableDef, junction_table: &TableDef, junction_pk: &HashSet, schema: &[TableDef], - used: &mut HashSet, -) -> Option> { +) -> Option> { // Junction table must have composite PK (2+ columns) if junction_pk.len() < 2 { return None; @@ -744,20 +951,23 @@ fn detect_many_to_many( fks.iter() .find(|(_, ref_table)| ref_table == ¤t_table.name)?; - // Generate many-to-many relations via this junction table - let mut out = Vec::new(); + let mut relations = Vec::new(); - // First, add has_many to the junction table itself + // First, add has_many to the junction table itself (direct relation, not M2M) let junction_base = pluralize(&sanitize_field_name(&junction_table.name)); - let junction_field_name = unique_name(&junction_base, used); - out.push(" #[sea_orm(has_many)]".to_string()); - out.push(format!( - " pub {junction_field_name}: HasMany,", - junction_table.name - )); + relations.push(ReverseRelation { + target_entity: junction_table.name.clone(), + is_one_to_one: false, + field_base: junction_base, + base_relation_enum: to_pascal_case(&junction_table.name), + source_table: junction_table.name.clone(), + has_multiple_fks: false, + via: None, + is_m2m: false, + }); - // Then add has_many with via for the target tables - for (_, ref_table) in &fks { + // Then add has_many with via for the target tables (M2M relations) + for (_columns, ref_table) in &fks { // Skip the FK to the current table itself if ref_table == ¤t_table.name { continue; @@ -769,20 +979,34 @@ fn detect_many_to_many( continue; } - // Generate has_many with via - let base = pluralize(&sanitize_field_name(ref_table)); - let field_name = unique_name(&base, used); + // M2M field name: {target}_via_{junction} to distinguish from direct relations + // e.g., "medias_via_user_media_role" instead of "medias" (which collides with direct FK) + let field_base = format!( + "{}_via_{}", + pluralize(&sanitize_field_name(ref_table)), + sanitize_field_name(&junction_table.name) + ); + // M2M relation_enum: {Target}Via{Junction} pattern + // e.g., "MediaViaUserMediaRole" for media through user_media_role + let base_relation_enum = format!( + "{}Via{}", + to_pascal_case(ref_table), + to_pascal_case(&junction_table.name) + ); - out.push(format!( - " #[sea_orm(has_many, via = \"{}\")]", - junction_table.name - )); - out.push(format!( - " pub {field_name}: HasMany,", - )); + relations.push(ReverseRelation { + target_entity: ref_table.clone(), + is_one_to_one: false, + field_base, + base_relation_enum, + source_table: junction_table.name.clone(), + has_multiple_fks: false, + via: Some(junction_table.name.clone()), + is_m2m: true, + }); } - Some(out) + Some(relations) } /// Simple pluralization for field names (adds 's' suffix). @@ -2361,6 +2585,7 @@ mod tests { #[case("many_to_many_article")] #[case("many_to_many_user")] #[case("many_to_many_missing_target")] + #[case("many_to_many_multiple_junctions")] #[case("composite_fk_parent")] #[case("not_junction_single_pk")] #[case("not_junction_fk_not_in_pk_other")] @@ -2442,6 +2667,50 @@ mod tests { ); (article.clone(), vec![article, article_user]) } + "many_to_many_multiple_junctions" => { + // Test case: user has M2M to media via TWO different junction tables + // This triggers relation_enum for M2M relations (line 664) + let user = table_with_pk( + "user", + vec![col("id", ColumnType::Simple(Uuid))], + vec!["id"], + ); + let media = table_with_pk( + "media", + vec![col("id", ColumnType::Simple(Uuid))], + vec!["id"], + ); + // First junction: user_media_role (e.g., user's role-based access to media) + let user_media_role = table_with_pk_and_fk( + "user_media_role", + vec![ + col("user_id", ColumnType::Simple(Uuid)), + col("media_id", ColumnType::Simple(Uuid)), + ], + vec!["user_id", "media_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["media_id"], "media", vec!["id"]), + ], + ); + // Second junction: user_media_favorite (e.g., user's favorites) + let user_media_favorite = table_with_pk_and_fk( + "user_media_favorite", + vec![ + col("user_id", ColumnType::Simple(Uuid)), + col("media_id", ColumnType::Simple(Uuid)), + ], + vec!["user_id", "media_id"], + vec![ + (vec!["user_id"], "user", vec!["id"]), + (vec!["media_id"], "media", vec!["id"]), + ], + ); + ( + user.clone(), + vec![user, media, user_media_role, user_media_favorite], + ) + } "composite_fk_parent" => { let parent = table_with_pk( "parent", diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_article.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_article.snap index 8f12ca8..92c058d 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_article.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_article.snap @@ -13,7 +13,7 @@ pub struct Model { #[sea_orm(has_many)] pub article_users: HasMany, #[sea_orm(has_many, via = "article_user")] - pub users: HasMany, + pub users_via_article_user: HasMany, } vespera::schema_type!(Schema from Model, name = "ArticleSchema"); diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_multiple_junctions.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_multiple_junctions.snap new file mode 100644 index 0000000..5aa4c9d --- /dev/null +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_multiple_junctions.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/seaorm/mod.rs +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + #[sea_orm(has_many)] + pub user_media_roles: HasMany, + #[sea_orm(has_many, relation_enum = "MediaViaUserMediaRole", via = "user_media_role")] + pub medias_via_user_media_role: HasMany, + #[sea_orm(has_many)] + pub user_media_favorites: HasMany, + #[sea_orm(has_many, relation_enum = "MediaViaUserMediaFavorite", via = "user_media_favorite")] + pub medias_via_user_media_favorite: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_user.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_user.snap index bf88411..a6917e6 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_user.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_many_to_many_user.snap @@ -13,7 +13,7 @@ pub struct Model { #[sea_orm(has_many)] pub article_users: HasMany, #[sea_orm(has_many, via = "article_user")] - pub articles: HasMany, + pub articles_via_article_user: HasMany, } vespera::schema_type!(Schema from Model, name = "UserSchema"); diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap index 0971fd7..dbbde96 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_has_one_relations.snap @@ -10,9 +10,9 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - #[sea_orm(has_one, relation_enum = "CreatedByUser", via_rel = "CreatedByUser")] + #[sea_orm(has_one, relation_enum = "Settings", via = "settings")] pub created_by_user: HasOne, - #[sea_orm(has_one, relation_enum = "UpdatedByUser", via_rel = "UpdatedByUser")] + #[sea_orm(has_one, relation_enum = "UpdatedByUser", via = "settings")] pub updated_by_user: HasOne, } diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap index edc1642..14c6260 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_with_schema_snapshots@schema_multiple_reverse_relations.snap @@ -10,9 +10,9 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, - #[sea_orm(has_many, relation_enum = "PreferredUser", via_rel = "PreferredUser")] + #[sea_orm(has_many, relation_enum = "Profile", via = "profile")] pub preferred_user_profiles: HasMany, - #[sea_orm(has_many, relation_enum = "BackupUser", via_rel = "BackupUser")] + #[sea_orm(has_many, relation_enum = "BackupUser", via = "profile")] pub backup_user_profiles: HasMany, } diff --git a/crates/vespertide-naming/src/lib.rs b/crates/vespertide-naming/src/lib.rs index 7cf8b13..a872166 100644 --- a/crates/vespertide-naming/src/lib.rs +++ b/crates/vespertide-naming/src/lib.rs @@ -4,6 +4,202 @@ //! indexes, constraints, and foreign keys. It has no dependencies and can be //! used by any other vespertide crate. +// ============================================================================ +// Relation Naming (for ORM exporters) +// ============================================================================ + +/// Extract semantic prefix from FK column for reverse relation naming. +/// +/// Given an FK column name, the current (target) table name, and the referenced +/// column name (e.g., "id", "idx"), extracts the semantic role portion. +/// +/// # Arguments +/// * `fk_column` - The FK column name (e.g., "user_id", "answered_by_user_id", "author_id") +/// * `current_table` - The table being referenced (e.g., "user") +/// * `ref_column` - The referenced column name (e.g., "id", "idx", "pk") +/// +/// # Returns +/// The semantic prefix (empty string for default FK, or the role/prefix for others) +/// +/// # Examples +/// ``` +/// use vespertide_naming::extract_relation_prefix; +/// +/// // Default FK: column matches table name + ref_column suffix +/// assert_eq!(extract_relation_prefix("user_id", "user", "id"), ""); +/// assert_eq!(extract_relation_prefix("user_idx", "user", "idx"), ""); +/// +/// // Prefixed FK: has semantic prefix before table name +/// assert_eq!(extract_relation_prefix("answered_by_user_id", "user", "id"), "answered_by"); +/// assert_eq!(extract_relation_prefix("target_user_id", "user", "id"), "target"); +/// +/// // Role FK: column doesn't end with table name +/// assert_eq!(extract_relation_prefix("author_id", "user", "id"), "author"); +/// assert_eq!(extract_relation_prefix("owner_id", "user", "id"), "owner"); +/// ``` +pub fn extract_relation_prefix(fk_column: &str, current_table: &str, ref_column: &str) -> String { + // Build the suffix to strip: _{ref_column} (e.g., "_id", "_idx") + let ref_suffix = format!("_{}", ref_column); + + // Remove the ref_column suffix if present + let without_ref = if fk_column.ends_with(&ref_suffix) { + &fk_column[..fk_column.len() - ref_suffix.len()] + } else { + fk_column + }; + + let current_lower = current_table.to_lowercase(); + let without_ref_lower = without_ref.to_lowercase(); + + // Case 1: FK column exactly matches current table (e.g., "user_id" for table "user") + // This is the "default" FK - return empty prefix + if without_ref_lower == current_lower { + return String::new(); + } + + // Case 2: FK column ends with _{current_table} (e.g., "answered_by_user_id" for table "user") + // Strip the _{table} suffix to get the semantic prefix + let table_suffix = format!("_{}", current_lower); + if without_ref_lower.ends_with(&table_suffix) { + let prefix_len = without_ref.len() - table_suffix.len(); + return without_ref[..prefix_len].to_string(); + } + + // Case 3: FK column is a different role (e.g., "author_id" for table "user") + // Use the column name as the prefix + without_ref.to_string() +} + +/// Generate reverse relation field name for has_many/has_one relations. +/// +/// # Arguments +/// * `fk_columns` - The FK column names +/// * `current_table` - The table being referenced (e.g., "user") +/// * `source_table` - The table that has the FK (e.g., "inquiry") +/// * `ref_column` - The referenced column name (e.g., "id") +/// * `has_multiple_fks` - Whether source_table has multiple FKs to current_table +/// * `is_one_to_one` - Whether this is a has_one relation +/// +/// # Returns +/// The field name (e.g., "inquiries", "answered_by_inquiries") +pub fn build_reverse_relation_field_name( + fk_columns: &[String], + current_table: &str, + source_table: &str, + ref_column: &str, + has_multiple_fks: bool, + is_one_to_one: bool, +) -> String { + let base_name = if is_one_to_one { + source_table.to_string() + } else { + pluralize(source_table) + }; + + if !has_multiple_fks || fk_columns.is_empty() { + return base_name; + } + + let prefix = extract_relation_prefix(&fk_columns[0], current_table, ref_column); + + if prefix.is_empty() { + base_name + } else { + format!("{}_{}", prefix, base_name) + } +} + +/// Generate relation enum name for FK relations. +/// +/// Uses the same logic as field naming but converts to PascalCase. +/// This ensures relation_enum aligns with field names for consistency. +/// +/// # Examples +/// ``` +/// use vespertide_naming::build_relation_enum_name; +/// +/// assert_eq!(build_relation_enum_name(&["user_id".into()], "user", "id"), ""); +/// assert_eq!(build_relation_enum_name(&["answered_by_user_id".into()], "user", "id"), "AnsweredBy"); +/// assert_eq!(build_relation_enum_name(&["author_id".into()], "user", "id"), "Author"); +/// ``` +pub fn build_relation_enum_name( + fk_columns: &[String], + current_table: &str, + ref_column: &str, +) -> String { + if fk_columns.is_empty() { + return String::new(); + } + + let prefix = extract_relation_prefix(&fk_columns[0], current_table, ref_column); + + if prefix.is_empty() { + String::new() + } else { + to_pascal_case(&prefix) + } +} + +/// Convert snake_case to PascalCase. +/// +/// # Examples +/// ``` +/// use vespertide_naming::to_pascal_case; +/// +/// assert_eq!(to_pascal_case("hello_world"), "HelloWorld"); +/// assert_eq!(to_pascal_case("answered_by"), "AnsweredBy"); +/// assert_eq!(to_pascal_case("user"), "User"); +/// ``` +pub fn to_pascal_case(s: &str) -> String { + let mut result = String::new(); + let mut capitalize = true; + for c in s.chars() { + let is_separator = c == '_' || c == '-'; + if is_separator { + capitalize = true; + continue; + } + let ch = if capitalize { + c.to_ascii_uppercase() + } else { + c + }; + capitalize = false; + result.push(ch); + } + result +} + +/// Simple pluralization for relation field names. +/// +/// # Examples +/// ``` +/// use vespertide_naming::pluralize; +/// +/// assert_eq!(pluralize("inquiry"), "inquiries"); +/// assert_eq!(pluralize("comment"), "comments"); +/// assert_eq!(pluralize("status"), "status"); +/// ``` +pub fn pluralize(name: &str) -> String { + if name.ends_with('s') || name.ends_with("es") { + name.to_string() + } else if name.ends_with('y') + && !name.ends_with("ay") + && !name.ends_with("ey") + && !name.ends_with("oy") + && !name.ends_with("uy") + { + // e.g., category -> categories, inquiry -> inquiries + format!("{}ies", &name[..name.len() - 1]) + } else { + format!("{}s", name) + } +} + +// ============================================================================ +// Constraint Naming (for SQL generation) +// ============================================================================ + /// Generate index name from table name, columns, and optional user-provided key. /// Always includes table name to avoid conflicts across tables. /// Uses double underscore to separate table name from the rest. @@ -58,6 +254,196 @@ pub fn build_enum_type_name(table: &str, enum_name: &str) -> String { mod tests { use super::*; + // ======================================================================== + // Relation Naming Tests + // ======================================================================== + + #[test] + fn test_extract_relation_prefix_default_fk() { + // Default FK: column matches table name + ref_column suffix + assert_eq!(extract_relation_prefix("user_id", "user", "id"), ""); + assert_eq!(extract_relation_prefix("org_id", "org", "id"), ""); + assert_eq!(extract_relation_prefix("post_id", "post", "id"), ""); + } + + #[test] + fn test_extract_relation_prefix_different_ref_column() { + // Handle different ref_column suffixes (not just _id) + assert_eq!(extract_relation_prefix("user_idx", "user", "idx"), ""); + assert_eq!(extract_relation_prefix("user_pk", "user", "pk"), ""); + assert_eq!(extract_relation_prefix("user_key", "user", "key"), ""); + } + + #[test] + fn test_extract_relation_prefix_semantic_prefix() { + // Prefixed FK: has semantic prefix before table name + assert_eq!( + extract_relation_prefix("answered_by_user_id", "user", "id"), + "answered_by" + ); + assert_eq!( + extract_relation_prefix("created_by_user_id", "user", "id"), + "created_by" + ); + assert_eq!( + extract_relation_prefix("target_user_id", "user", "id"), + "target" + ); + assert_eq!( + extract_relation_prefix("parent_org_id", "org", "id"), + "parent" + ); + } + + #[test] + fn test_extract_relation_prefix_role_fk() { + // Role FK: column doesn't end with table name + assert_eq!(extract_relation_prefix("author_id", "user", "id"), "author"); + assert_eq!(extract_relation_prefix("owner_id", "user", "id"), "owner"); + assert_eq!( + extract_relation_prefix("creator_id", "user", "id"), + "creator" + ); + } + + #[test] + fn test_extract_relation_prefix_no_suffix() { + // Edge case: no ref_column suffix + assert_eq!(extract_relation_prefix("user", "user", "id"), ""); + assert_eq!(extract_relation_prefix("admin_user", "user", "id"), "admin"); + } + + #[test] + fn test_build_reverse_relation_field_name_single_fk() { + // Single FK - just use source table name + assert_eq!( + build_reverse_relation_field_name( + &["user_id".into()], + "user", + "inquiry", + "id", + false, + false + ), + "inquiries" + ); + assert_eq!( + build_reverse_relation_field_name( + &["author_id".into()], + "user", + "comment", + "id", + false, + false + ), + "comments" + ); + } + + #[test] + fn test_build_reverse_relation_field_name_multiple_fks() { + // Multiple FKs - need disambiguation + assert_eq!( + build_reverse_relation_field_name( + &["user_id".into()], + "user", + "inquiry", + "id", + true, + false + ), + "inquiries" + ); + assert_eq!( + build_reverse_relation_field_name( + &["answered_by_user_id".into()], + "user", + "inquiry", + "id", + true, + false + ), + "answered_by_inquiries" + ); + } + + #[test] + fn test_build_reverse_relation_field_name_one_to_one() { + assert_eq!( + build_reverse_relation_field_name( + &["user_id".into()], + "user", + "profile", + "id", + false, + true + ), + "profile" + ); + assert_eq!( + build_reverse_relation_field_name( + &["backup_user_id".into()], + "user", + "settings", + "id", + true, + true + ), + "backup_settings" + ); + } + + #[test] + fn test_build_relation_enum_name() { + // Empty fk_columns - early return + assert_eq!(build_relation_enum_name(&[], "user", "id"), ""); + + // Default FK - empty enum name (not needed or use table name) + assert_eq!( + build_relation_enum_name(&["user_id".into()], "user", "id"), + "" + ); + + // Semantic prefix - PascalCase + assert_eq!( + build_relation_enum_name(&["answered_by_user_id".into()], "user", "id"), + "AnsweredBy" + ); + assert_eq!( + build_relation_enum_name(&["target_user_id".into()], "user", "id"), + "Target" + ); + + // Role FK - PascalCase of role + assert_eq!( + build_relation_enum_name(&["author_id".into()], "user", "id"), + "Author" + ); + } + + #[test] + fn test_to_pascal_case() { + assert_eq!(to_pascal_case("hello_world"), "HelloWorld"); + assert_eq!(to_pascal_case("answered_by"), "AnsweredBy"); + assert_eq!(to_pascal_case("user"), "User"); + assert_eq!(to_pascal_case("hello-world"), "HelloWorld"); + assert_eq!(to_pascal_case(""), ""); + } + + #[test] + fn test_pluralize() { + assert_eq!(pluralize("inquiry"), "inquiries"); + assert_eq!(pluralize("category"), "categories"); + assert_eq!(pluralize("comment"), "comments"); + assert_eq!(pluralize("user"), "users"); + assert_eq!(pluralize("status"), "status"); + assert_eq!(pluralize("address"), "address"); + } + + // ======================================================================== + // Constraint Naming Tests + // ======================================================================== + #[test] fn test_build_index_name_with_key() { assert_eq!( diff --git a/examples/app/Cargo.toml b/examples/app/Cargo.toml index 6197477..01378d2 100644 --- a/examples/app/Cargo.toml +++ b/examples/app/Cargo.toml @@ -9,3 +9,5 @@ vespertide = { path = "../../crates/vespertide" } tokio = { version = "1", features = ["full"] } sea-orm = { version = "2.0.0-rc.30", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-native-tls", "macros"] } anyhow = "1" +serde = { version = "1", features = ["derive"] } +vespera = "0.1.32" diff --git a/examples/app/src/main.rs b/examples/app/src/main.rs index bd88e0f..fcbbb6c 100644 --- a/examples/app/src/main.rs +++ b/examples/app/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Result; use sea_orm::{ConnectOptions, Database}; use std::time::Duration; +pub mod models; #[tokio::main] async fn main() -> Result<()> { diff --git a/examples/app/src/models/article.rs b/examples/app/src/models/article.rs index 614952f..f4b00a8 100644 --- a/examples/app/src/models/article.rs +++ b/examples/app/src/models/article.rs @@ -1,7 +1,10 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[derive( + Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema, +)] +#[serde(rename_all = "camelCase")] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "article_status")] pub enum Status { #[sea_orm(string_value = "draft")] @@ -15,7 +18,7 @@ pub enum Status { } #[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "article")] pub struct Model { #[sea_orm(primary_key)] @@ -38,11 +41,11 @@ pub struct Model { #[sea_orm(has_many)] pub article_users: HasMany, #[sea_orm(has_many, via = "article_user")] - pub users: HasMany, + pub users_via_article_user: HasMany, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [status] // (unnamed) on [published_at] +vespera::schema_type!(Schema from Model, name = "ArticleSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/article_user.rs b/examples/app/src/models/article_user.rs index 5b6c0db..9349a1f 100644 --- a/examples/app/src/models/article_user.rs +++ b/examples/app/src/models/article_user.rs @@ -1,7 +1,10 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] +#[derive( + Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema, +)] +#[serde(rename_all = "camelCase")] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "article_user_role")] pub enum Role { #[sea_orm(string_value = "lead")] @@ -11,7 +14,7 @@ pub enum Role { } #[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "article_user")] pub struct Model { #[sea_orm(primary_key)] @@ -32,7 +35,7 @@ pub struct Model { pub user: HasOne, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] +vespera::schema_type!(Schema from Model, name = "ArticleUserSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/media.rs b/examples/app/src/models/media.rs index 028c742..94a77f4 100644 --- a/examples/app/src/models/media.rs +++ b/examples/app/src/models/media.rs @@ -1,8 +1,7 @@ use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; #[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "media")] pub struct Model { /// hello @@ -16,17 +15,21 @@ pub struct Model { #[sea_orm(default_value = "now()")] pub created_at: DateTimeWithTimeZone, pub updated_at: Option, - #[sea_orm(belongs_to, from = "owner_id", to = "id")] + #[sea_orm(belongs_to, relation_enum = "Owner", from = "owner_id", to = "id")] pub owner: HasOne, #[sea_orm(has_many)] pub articles: HasMany, #[sea_orm(has_many)] pub user_media_roles: HasMany, - #[sea_orm(has_many, via = "user_media_role")] - pub users: HasMany, + #[sea_orm( + has_many, + relation_enum = "UserViaUserMediaRole", + via = "user_media_role" + )] + pub users_via_user_media_role: HasMany, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [owner_id] +vespera::schema_type!(Schema from Model, name = "MediaSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user.rs b/examples/app/src/models/user.rs index b4e8caa..763a609 100644 --- a/examples/app/src/models/user.rs +++ b/examples/app/src/models/user.rs @@ -1,8 +1,7 @@ use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; #[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user")] pub struct Model { #[sea_orm(primary_key, default_value = "gen_random_uuid()")] @@ -18,16 +17,20 @@ pub struct Model { #[sea_orm(has_many)] pub article_users: HasMany, #[sea_orm(has_many, via = "article_user")] - pub articles: HasMany, - #[sea_orm(has_many)] + pub articles_via_article_user: HasMany, + #[sea_orm(has_many, relation_enum = "Media", via = "media")] pub medias: HasMany, #[sea_orm(has_many)] pub user_media_roles: HasMany, - #[sea_orm(has_many, via = "user_media_role")] - pub medias_1: HasMany, + #[sea_orm( + has_many, + relation_enum = "MediaViaUserMediaRole", + via = "user_media_role" + )] + pub medias_via_user_media_role: HasMany, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [email] +vespera::schema_type!(Schema from Model, name = "UserSchema"); impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user_media_role.rs b/examples/app/src/models/user_media_role.rs index bcb43c5..eff2b8f 100644 --- a/examples/app/src/models/user_media_role.rs +++ b/examples/app/src/models/user_media_role.rs @@ -1,8 +1,15 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema)] -#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_media_role_role")] +#[derive( + Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, vespera::Schema, +)] +#[serde(rename_all = "camelCase")] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "user_media_role_role" +)] pub enum Role { #[sea_orm(string_value = "owner")] Owner, @@ -14,7 +21,7 @@ pub enum Role { /// hello media role #[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user_media_role")] pub struct Model { /// hello @@ -32,9 +39,9 @@ pub struct Model { pub media: HasOne, } - // Index definitions (SeaORM uses Statement builders externally) // (unnamed) on [user_id] // (unnamed) on [media_id] // (unnamed) on [role] +vespera::schema_type!(Schema from Model, name = "UserMediaRoleSchema"); impl ActiveModelBehavior for ActiveModel {}