diff --git a/.changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json b/.changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json new file mode 100644 index 0000000..8ee5bac --- /dev/null +++ b/.changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch"},"note":"Add testcase","date":"2025-12-10T15:18:28.271409100Z"} \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 19f3e9a..5079553 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -36,7 +36,18 @@ jobs: - name: Build run: cargo check - name: Test - run: cargo tarpaulin --out Lcov + run: | + # rust coverage issue + echo 'max_width = 100000' > .rustfmt.toml + echo 'tab_spaces = 4' >> .rustfmt.toml + echo 'newline_style = "Unix"' >> .rustfmt.toml + echo 'fn_call_width = 100000' >> .rustfmt.toml + echo 'fn_params_layout = "Compressed"' >> .rustfmt.toml + echo 'chain_width = 100000' >> .rustfmt.toml + echo 'merge_derives = true' >> .rustfmt.toml + echo 'use_small_heuristics = "Default"' >> .rustfmt.toml + cargo fmt + cargo tarpaulin --out Lcov - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: diff --git a/Cargo.lock b/Cargo.lock index 4555b4c..7c3f987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -86,6 +86,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + [[package]] name = "bumpalo" version = "3.19.0" @@ -166,6 +172,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -184,18 +199,76 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -207,6 +280,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "futures-task" version = "0.3.31" @@ -225,14 +304,30 @@ 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", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "glob" version = "0.3.3" @@ -313,6 +408,21 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -346,6 +456,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -385,6 +518,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -478,6 +626,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -490,6 +651,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schemars" version = "1.1.0" @@ -515,6 +685,18 @@ dependencies = [ "syn", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -588,6 +770,31 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shlex" version = "1.3.0" @@ -600,6 +807,12 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "strsim" version = "0.11.1" @@ -617,6 +830,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.17" @@ -699,9 +925,13 @@ dependencies = [ "anyhow", "chrono", "clap", + "colored", + "rstest", "schemars", "serde_json", "serde_yaml", + "serial_test", + "tempfile", "vespertide-config", "vespertide-core", "vespertide-planner", @@ -745,6 +975,7 @@ dependencies = [ name = "vespertide-query" version = "0.1.0" dependencies = [ + "rstest", "thiserror", "vespertide-core", ] @@ -757,9 +988,19 @@ dependencies = [ "clap", "schemars", "serde_json", + "tempfile", "vespertide-core", ] +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.106" @@ -864,6 +1105,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -873,6 +1123,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.14" @@ -881,3 +1195,9 @@ checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 4546c6e..95e65b6 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -13,6 +13,7 @@ publish = true anyhow = "1" clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +colored = "3" serde_json = "1" serde_yaml = "0.9" schemars = "1.1" @@ -21,6 +22,11 @@ vespertide-core = { workspace = true } vespertide-planner = { workspace = true } vespertide-query = { workspace = true } +[dev-dependencies] +tempfile = "3" +serial_test = "3" +rstest = "0.26" + [[bin]] name = "vespertide" path = "src/main.rs" diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index 7ecce10..a2afbe7 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use colored::Colorize; use vespertide_planner::plan_next_migration; use crate::utils::{load_config, load_migrations, load_models}; @@ -13,48 +14,257 @@ pub fn cmd_diff() -> Result<()> { .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; if plan.actions.is_empty() { - println!("No differences found. Schema is up to date."); - return Ok(()); - } - - println!("Found {} change(s) to apply:", plan.actions.len()); - println!(); + println!( + "{} {}", + "No differences found.".bright_green(), + "Schema is up to date.".bright_white() + ); + } else { + println!( + "{} {} {}", + "Found".bright_cyan(), + plan.actions.len().to_string().bright_yellow().bold(), + "change(s) to apply:".bright_cyan() + ); + println!(); - for (i, action) in plan.actions.iter().enumerate() { - println!("{}. {}", i + 1, format_action(action)); + for (i, action) in plan.actions.iter().enumerate() { + println!( + "{}. {}", + (i + 1).to_string().bright_magenta().bold(), + format_action(action) + ); + } } - Ok(()) } fn format_action(action: &MigrationAction) -> String { match action { MigrationAction::CreateTable { table, .. } => { - format!("Create table: {}", table) + format!( + "{} {}", + "Create table:".bright_green(), + table.bright_cyan().bold() + ) } MigrationAction::DeleteTable { table } => { - format!("Delete table: {}", table) + format!( + "{} {}", + "Delete table:".bright_red(), + table.bright_cyan().bold() + ) } MigrationAction::AddColumn { table, column, .. } => { - format!("Add column: {}.{}", table, column.name) + format!( + "{} {}.{}", + "Add column:".bright_green(), + table.bright_cyan(), + column.name.bright_cyan().bold() + ) } MigrationAction::RenameColumn { table, from, to } => { - format!("Rename column: {}.{} -> {}", table, from, to) + format!( + "{} {}.{} {} {}", + "Rename column:".bright_yellow(), + table.bright_cyan(), + from.bright_white(), + "->".bright_white(), + to.bright_cyan().bold() + ) } MigrationAction::DeleteColumn { table, column } => { - format!("Delete column: {}.{}", table, column) + format!( + "{} {}.{}", + "Delete column:".bright_red(), + table.bright_cyan(), + column.bright_cyan().bold() + ) } MigrationAction::ModifyColumnType { table, column, .. } => { - format!("Modify column type: {}.{}", table, column) + format!( + "{} {}.{}", + "Modify column type:".bright_yellow(), + table.bright_cyan(), + column.bright_cyan().bold() + ) } MigrationAction::AddIndex { table, index } => { - format!("Add index: {} on {}", index.name, table) + format!( + "{} {} {} {}", + "Add index:".bright_green(), + index.name.bright_cyan().bold(), + "on".bright_white(), + table.bright_cyan() + ) } MigrationAction::RemoveIndex { table, name } => { - format!("Remove index: {} from {}", name, table) + format!( + "{} {} {} {}", + "Remove index:".bright_red(), + name.bright_cyan().bold(), + "from".bright_white(), + table.bright_cyan() + ) } MigrationAction::RenameTable { from, to } => { - format!("Rename table: {} -> {}", from, to) + format!( + "{} {} {} {}", + "Rename table:".bright_yellow(), + from.bright_cyan(), + "->".bright_white(), + to.bright_cyan().bold() + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use colored::Colorize; + use rstest::rstest; + use serial_test::serial; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + use vespertide_config::VespertideConfig; + use vespertide_core::{ColumnDef, ColumnType, TableDef}; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); } } + + fn write_config() { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + fs::write("vespertide.json", text).unwrap(); + } + + fn write_model(name: &str) { + let models_dir = PathBuf::from("models"); + fs::create_dir_all(&models_dir).unwrap(); + let table = TableDef { + name: name.to_string(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![], + indexes: vec![], + }; + let path = models_dir.join(format!("{name}.json")); + fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); + } + + #[rstest] + #[case( + MigrationAction::CreateTable { table: "users".into(), columns: vec![], constraints: vec![] }, + format!("{} {}", "Create table:".bright_green(), "users".bright_cyan().bold()) + )] + #[case( + MigrationAction::DeleteTable { table: "users".into() }, + format!("{} {}", "Delete table:".bright_red(), "users".bright_cyan().bold()) + )] + #[case( + MigrationAction::AddColumn { + table: "users".into(), + column: ColumnDef { + name: "name".into(), + r#type: ColumnType::Text, + nullable: true, + default: None, + }, + fill_with: None, + }, + format!("{} {}.{}", "Add column:".bright_green(), "users".bright_cyan(), "name".bright_cyan().bold()) + )] + #[case( + MigrationAction::RenameColumn { + table: "users".into(), + from: "old".into(), + to: "new".into(), + }, + format!("{} {}.{} {} {}", "Rename column:".bright_yellow(), "users".bright_cyan(), "old".bright_white(), "->".bright_white(), "new".bright_cyan().bold()) + )] + #[case( + MigrationAction::DeleteColumn { table: "users".into(), column: "name".into() }, + format!("{} {}.{}", "Delete column:".bright_red(), "users".bright_cyan(), "name".bright_cyan().bold()) + )] + #[case( + MigrationAction::ModifyColumnType { + table: "users".into(), + column: "id".into(), + new_type: ColumnType::Integer, + }, + format!("{} {}.{}", "Modify column type:".bright_yellow(), "users".bright_cyan(), "id".bright_cyan().bold()) + )] + #[case( + MigrationAction::AddIndex { + table: "users".into(), + index: vespertide_core::IndexDef { + name: "idx".into(), + columns: vec!["id".into()], + unique: false, + }, + }, + format!("{} {} {} {}", "Add index:".bright_green(), "idx".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::RemoveIndex { table: "users".into(), name: "idx".into() }, + format!("{} {} {} {}", "Remove index:".bright_red(), "idx".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan()) + )] + #[case( + MigrationAction::RenameTable { from: "users".into(), to: "accounts".into() }, + format!("{} {} {} {}", "Rename table:".bright_yellow(), "users".bright_cyan(), "->".bright_white(), "accounts".bright_cyan().bold()) + )] + #[serial] + fn format_action_cases(#[case] action: MigrationAction, #[case] expected: String) { + assert_eq!(format_action(&action), expected); + } + + #[rstest] + #[serial] + fn cmd_diff_with_model_and_no_migrations() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + write_config(); + write_model("users"); + fs::create_dir_all("migrations").unwrap(); + + let result = cmd_diff(); + assert!(result.is_ok()); + } + + #[rstest] + #[serial] + fn cmd_diff_when_no_changes() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + write_config(); + // No models, no migrations -> planner should report no actions. + fs::create_dir_all("models").unwrap(); + fs::create_dir_all("migrations").unwrap(); + + let result = cmd_diff(); + assert!(result.is_ok()); + } } diff --git a/crates/vespertide-cli/src/commands/init.rs b/crates/vespertide-cli/src/commands/init.rs index 353b0fd..a8422da 100644 --- a/crates/vespertide-cli/src/commands/init.rs +++ b/crates/vespertide-cli/src/commands/init.rs @@ -1,6 +1,7 @@ use std::{fs, path::PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; +use colored::Colorize; use vespertide_config::VespertideConfig; pub fn cmd_init() -> Result<()> { @@ -12,7 +13,56 @@ 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")?; - println!("created {:?}", path); + println!( + "{} {}", + "created".bright_green().bold(), + format!("{}", path.display()).bright_white() + ); Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use tempfile::tempdir; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } + } + + #[test] + #[serial_test::serial] + fn cmd_init_creates_config() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + cmd_init().unwrap(); + assert!(PathBuf::from("vespertide.json").exists()); + } + + #[test] + #[serial_test::serial] + 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(); + 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 4bf6e2b..0e1b1ea 100644 --- a/crates/vespertide-cli/src/commands/log.rs +++ b/crates/vespertide-cli/src/commands/log.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use colored::Colorize; use vespertide_query::build_plan_queries; use crate::utils::load_migrations; @@ -7,31 +8,56 @@ pub fn cmd_log() -> Result<()> { let plans = load_migrations(&crate::utils::load_config()?)?; if plans.is_empty() { - println!("No migrations found."); + println!("{}", "No migrations found.".bright_yellow()); return Ok(()); } - println!("Migrations (oldest -> newest): {}", plans.len()); + println!( + "{} {} {}", + "Migrations".bright_cyan().bold(), + "(oldest -> newest):".bright_white(), + plans.len().to_string().bright_yellow().bold() + ); println!(); for plan in &plans { - println!("Version: {}", plan.version); + println!( + "{} {}", + "Version:".bright_cyan().bold(), + plan.version.to_string().bright_magenta().bold() + ); if let Some(created) = &plan.created_at { - println!("Created at: {}", created); + println!( + " {} {}", + "Created at:".bright_cyan(), + created.bright_white() + ); } if let Some(comment) = &plan.comment { - println!("Comment: {}", comment); + println!(" {} {}", "Comment:".bright_cyan(), comment.bright_white()); } - println!("Actions: {}", plan.actions.len()); + println!( + " {} {}", + "Actions:".bright_cyan(), + plan.actions.len().to_string().bright_yellow() + ); let queries = build_plan_queries(plan) .map_err(|e| anyhow::anyhow!("query build error for v{}: {}", plan.version, e))?; - println!("SQL statements: {}", queries.len()); + println!( + " {} {}", + "SQL statements:".bright_cyan().bold(), + queries.len().to_string().bright_yellow().bold() + ); for (i, q) in queries.iter().enumerate() { - println!(" {}. {}", i + 1, q.sql.trim()); + println!( + " {}. {}", + (i + 1).to_string().bright_magenta().bold(), + q.sql.trim().bright_white() + ); if !q.binds.is_empty() { - println!(" binds: {:?}", q.binds); + println!(" {} {:?}", "binds:".bright_cyan(), q.binds); } } println!(); @@ -40,3 +66,78 @@ pub fn cmd_log() -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::{env, fs, path::PathBuf}; + use tempfile::tempdir; + use vespertide_config::VespertideConfig; + use vespertide_core::{MigrationAction, MigrationPlan}; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } + } + + fn write_config(cfg: &VespertideConfig) { + let text = serde_json::to_string_pretty(cfg).unwrap(); + fs::write("vespertide.json", text).unwrap(); + } + + fn write_migration(cfg: &VespertideConfig) { + fs::create_dir_all(cfg.migrations_dir()).unwrap(); + let plan = MigrationPlan { + comment: Some("init".into()), + created_at: Some("2024-01-01T00:00:00Z".into()), + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }], + }; + let path = cfg.migrations_dir().join("0001_init.json"); + fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); + } + + #[test] + #[serial_test::serial] + fn cmd_log_with_single_migration() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = VespertideConfig::default(); + write_config(&cfg); + write_migration(&cfg); + + let result = cmd_log(); + assert!(result.is_ok()); + } + + #[test] + #[serial_test::serial] + fn cmd_log_no_migrations() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = VespertideConfig::default(); + write_config(&cfg); + fs::create_dir_all(cfg.migrations_dir()).unwrap(); + + let result = cmd_log(); + assert!(result.is_ok()); + } +} diff --git a/crates/vespertide-cli/src/commands/new.rs b/crates/vespertide-cli/src/commands/new.rs index fa83823..55e12aa 100644 --- a/crates/vespertide-cli/src/commands/new.rs +++ b/crates/vespertide-cli/src/commands/new.rs @@ -1,6 +1,7 @@ use std::fs; use anyhow::{Context, Result, bail}; +use colored::Colorize; use serde_json::Value; use vespertide_core::TableDef; @@ -39,7 +40,11 @@ pub fn cmd_new(name: String, format: Option) -> Result<()> { FileFormat::Yaml | FileFormat::Yml => write_yaml(&path, &table, &schema_url)?, } - println!("Created model template: {}", path.display()); + println!( + "{} {}", + "Created model template:".bright_green().bold(), + format!("{}", path.display()).bright_white() + ); Ok(()) } @@ -71,6 +76,126 @@ fn write_json_with_schema( Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use tempfile::tempdir; + use vespertide_config::VespertideConfig; + + struct CwdGuard { + original: std::path::PathBuf, + } + + impl CwdGuard { + fn new(dir: &std::path::Path) -> Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } + } + + fn write_config(model_format: FileFormat) { + let mut cfg = VespertideConfig::default(); + cfg.model_format = model_format; + let text = serde_json::to_string_pretty(&cfg).unwrap(); + std::fs::write("vespertide.json", text).unwrap(); + } + + #[test] + #[serial_test::serial] + 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(); + + let cfg = VespertideConfig::default(); + let path = cfg.models_dir().join("users.json"); + assert!(path.exists()); + + let text = fs::read_to_string(path).unwrap(); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + value.get("$schema"), + Some(&serde_json::Value::String(expected_schema)) + ); + } + + #[test] + #[serial_test::serial] + 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(); + + let mut cfg = VespertideConfig::default(); + cfg.model_format = FileFormat::Yaml; + let path = cfg.models_dir().join("orders.yaml"); + assert!(path.exists()); + + let text = fs::read_to_string(path).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); + let schema = value + .as_mapping() + .and_then(|m| m.get(&serde_yaml::Value::String("$schema".into()))) + .and_then(|v| v.as_str()); + assert_eq!(schema, Some(expected_schema.as_str())); + } + + #[test] + #[serial_test::serial] + 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(); + + let mut cfg = VespertideConfig::default(); + cfg.model_format = FileFormat::Yml; + let path = cfg.models_dir().join("products.yml"); + assert!(path.exists()); + + let text = fs::read_to_string(path).unwrap(); + let value: serde_yaml::Value = serde_yaml::from_str(&text).unwrap(); + let schema = value + .as_mapping() + .and_then(|m| m.get(&serde_yaml::Value::String("$schema".into()))) + .and_then(|v| v.as_str()); + assert_eq!(schema, Some(expected_schema.as_str())); + } + + #[test] + #[serial_test::serial] + 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(); + let path = cfg.models_dir().join("users.json"); + std::fs::write(&path, "{}").unwrap(); + + let err = cmd_new("users".into(), None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("model file already exists")); + assert!(msg.contains("users.json")); + } +} fn write_yaml(path: &std::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 { diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index c03bd18..2d81355 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -2,6 +2,7 @@ use std::fs; use anyhow::{Context, Result}; use chrono::Utc; +use colored::Colorize; use vespertide_planner::plan_next_migration; use crate::utils::{ @@ -17,7 +18,11 @@ pub fn cmd_revision(message: String) -> Result<()> { .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; if plan.actions.is_empty() { - println!("No changes detected. Nothing to migrate."); + println!( + "{} {}", + "No changes detected.".bright_yellow(), + "Nothing to migrate.".bright_white() + ); return Ok(()); } @@ -29,8 +34,7 @@ pub fn cmd_revision(message: String) -> 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).context("create migrations directory")?; } let format = config.migration_format(); @@ -49,15 +53,142 @@ pub fn cmd_revision(message: String) -> Result<()> { _ => serde_yaml::to_string(&plan).context("serialize migration plan")?, }; - fs::write(&path, text) - .with_context(|| format!("write migration file: {}", path.display()))?; + fs::write(&path, text).with_context(|| format!("write migration file: {}", path.display()))?; - println!("Created migration: {}", path.display()); - println!(" Version: {}", plan.version); - println!(" Actions: {}", plan.actions.len()); + println!( + "{} {}", + "Created migration:".bright_green().bold(), + format!("{}", path.display()).bright_white() + ); + println!( + " {} {}", + "Version:".bright_cyan(), + plan.version.to_string().bright_magenta().bold() + ); + println!( + " {} {}", + "Actions:".bright_cyan(), + plan.actions.len().to_string().bright_yellow() + ); if let Some(comment) = &plan.comment { - println!(" Comment: {}", comment); + println!(" {} {}", "Comment:".bright_cyan(), comment.bright_white()); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{env, fs, path::PathBuf}; + use tempfile::tempdir; + use vespertide_config::{FileFormat, VespertideConfig}; + use vespertide_core::{ColumnDef, ColumnType, TableDef}; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } + } + + fn write_config() -> VespertideConfig { + write_config_with_format(None) + } + + fn write_config_with_format(fmt: Option) -> VespertideConfig { + let mut cfg = VespertideConfig::default(); + if let Some(f) = fmt { + cfg.migration_format = f; + } + let text = serde_json::to_string_pretty(&cfg).unwrap(); + 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(); + let table = TableDef { + name: name.to_string(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![], + indexes: vec![], + }; + let path = models_dir.join(format!("{name}.json")); + fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); + } + + #[test] + #[serial_test::serial] + 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(); + + cmd_revision("init".into()).unwrap(); + + let entries: Vec<_> = fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + assert!(!entries.is_empty()); + } + + #[test] + #[serial_test::serial] + 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()).is_ok()); + // migrations dir should not be created + assert!(!cfg.migrations_dir().exists()); + } + + #[test] + #[serial_test::serial] + fn cmd_revision_writes_yaml_when_configured() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config_with_format(Some(FileFormat::Yaml)); + 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(); + } + + cmd_revision("yaml".into()).unwrap(); + + let entries: Vec<_> = fs::read_dir(cfg.migrations_dir()).unwrap().collect(); + assert!(!entries.is_empty()); + let has_yaml = entries.iter().any(|e| { + e.as_ref() + .unwrap() + .path() + .extension() + .map(|s| s == "yaml") + .unwrap_or(false) + }); + assert!(has_yaml); + } +} diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index cee7a8b..a07338d 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use colored::Colorize; use vespertide_planner::plan_next_migration; use vespertide_query::build_plan_queries; @@ -12,32 +13,160 @@ pub fn cmd_sql() -> Result<()> { let plan = plan_next_migration(¤t_models, &applied_plans) .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + emit_sql(&plan) +} + +fn emit_sql(plan: &vespertide_core::MigrationPlan) -> Result<()> { if plan.actions.is_empty() { - println!("No differences found. Schema is up to date; no SQL to emit."); + println!( + "{} {}", + "No differences found.".bright_green(), + "Schema is up to date; no SQL to emit.".bright_white() + ); return Ok(()); } - let queries = build_plan_queries(&plan) - .map_err(|e| anyhow::anyhow!("query build error: {}", e))?; + let queries = + build_plan_queries(plan).map_err(|e| anyhow::anyhow!("query build error: {}", e))?; - println!("Plan version: {}", plan.version); + println!( + "{} {}", + "Plan version:".bright_cyan().bold(), + plan.version.to_string().bright_magenta() + ); if let Some(created_at) = &plan.created_at { - println!("Created at: {}", created_at); + println!( + "{} {}", + "Created at:".bright_cyan(), + created_at.bright_white() + ); } if let Some(comment) = &plan.comment { - println!("Comment: {}", comment); + println!("{} {}", "Comment:".bright_cyan(), comment.bright_white()); } - println!("Actions: {}", plan.actions.len()); - println!("SQL statements: {}", queries.len()); + println!( + "{} {}", + "Actions:".bright_cyan(), + plan.actions.len().to_string().bright_yellow() + ); + println!( + "{} {}", + "SQL statements:".bright_cyan().bold(), + queries.len().to_string().bright_yellow().bold() + ); println!(); for (i, q) in queries.iter().enumerate() { - println!("{}. {}", i + 1, q.sql.trim()); + println!( + "{}. {}", + (i + 1).to_string().bright_magenta().bold(), + q.sql.trim().bright_white() + ); if !q.binds.is_empty() { - println!(" binds: {:?}", q.binds); + println!(" {} {:?}", "binds:".bright_cyan(), q.binds); } } Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + use vespertide_config::VespertideConfig; + use vespertide_core::{ + ColumnDef, ColumnType, MigrationAction, MigrationPlan, TableConstraint, TableDef, + }; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + fn write_config() -> VespertideConfig { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + 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(); + let table = TableDef { + name: name.to_string(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![], + indexes: vec![], + }; + let path = models_dir.join(format!("{name}.json")); + fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); + } + + #[test] + #[serial] + fn cmd_sql_emits_queries() { + 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(); + + let result = cmd_sql(); + assert!(result.is_ok()); + } + + #[test] + fn emit_sql_no_actions_early_return() { + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![], + }; + assert!(emit_sql(&plan).is_ok()); + } + + #[test] + fn emit_sql_with_metadata() { + let plan = MigrationPlan { + comment: Some("init".into()), + created_at: Some("2024-01-01T00:00:00Z".into()), + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + }], + }; + assert!(emit_sql(&plan).is_ok()); + } +} diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index 4d04de9..e35523a 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use colored::Colorize; use vespertide_planner::schema_from_plans; use crate::utils::{load_config, load_migrations, load_models}; @@ -9,62 +10,254 @@ pub fn cmd_status() -> Result<()> { let current_models = load_models(&config)?; let applied_plans = load_migrations(&config)?; - println!("Configuration:"); - println!(" Models directory: {}", config.models_dir().display()); - println!(" Migrations directory: {}", config.migrations_dir().display()); - println!(" Table naming: {:?}", config.table_naming_case); - println!(" Column naming: {:?}", config.column_naming_case); - println!(" Model format: {:?}", config.model_format()); - println!(" Migration format: {:?}", config.migration_format()); + println!("{}", "Configuration:".bright_cyan().bold()); println!( - " Migration filename pattern: {}", - config.migration_filename_pattern() + " {} {}", + "Models directory:".cyan(), + format!("{}", config.models_dir().display()).bright_white() + ); + println!( + " {} {}", + "Migrations directory:".cyan(), + format!("{}", config.migrations_dir().display()).bright_white() + ); + println!( + " {} {:?}", + "Table naming:".cyan(), + config.table_naming_case + ); + println!( + " {} {:?}", + "Column naming:".cyan(), + config.column_naming_case + ); + println!(" {} {:?}", "Model format:".cyan(), config.model_format()); + println!( + " {} {:?}", + "Migration format:".cyan(), + config.migration_format() + ); + println!( + " {} {}", + "Migration filename pattern:".cyan(), + config.migration_filename_pattern().bright_white() ); println!(); - println!("Applied migrations: {}", applied_plans.len()); + println!( + "{} {}", + "Applied migrations:".bright_cyan().bold(), + applied_plans.len().to_string().bright_yellow() + ); if !applied_plans.is_empty() { let latest = applied_plans.last().unwrap(); - println!(" Latest version: {}", latest.version); + println!( + " {} {}", + "Latest version:".cyan(), + latest.version.to_string().bright_magenta() + ); if let Some(comment) = &latest.comment { - println!(" Latest comment: {}", comment); + println!(" {} {}", "Latest comment:".cyan(), comment.bright_white()); } if let Some(created_at) = &latest.created_at { - println!(" Latest created at: {}", created_at); + println!( + " {} {}", + "Latest created at:".cyan(), + created_at.bright_white() + ); } } println!(); - println!("Current models: {}", current_models.len()); + println!( + "{} {}", + "Current models:".bright_cyan().bold(), + current_models.len().to_string().bright_yellow() + ); for model in ¤t_models { - println!(" - {} ({} columns, {} indexes)", - model.name, - model.columns.len(), - model.indexes.len()); + println!( + " {} {} ({} {}, {} {})", + "-".bright_white(), + model.name.bright_green(), + model.columns.len().to_string().bright_blue(), + "columns".bright_white(), + model.indexes.len().to_string().bright_blue(), + "indexes".bright_white() + ); } println!(); if !applied_plans.is_empty() { let baseline = schema_from_plans(&applied_plans) .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; - - let baseline_tables: HashSet<_> = - baseline.iter().map(|t| &t.name).collect(); - let current_tables: HashSet<_> = - current_models.iter().map(|t| &t.name).collect(); + + let baseline_tables: HashSet<_> = baseline.iter().map(|t| &t.name).collect(); + let current_tables: HashSet<_> = current_models.iter().map(|t| &t.name).collect(); if baseline_tables == current_tables { - println!("Status: Schema is synchronized with migrations."); + println!( + "{} {}", + "Status:".bright_cyan().bold(), + "Schema is synchronized with migrations.".bright_green() + ); } else { - println!("Status: Schema differs from applied migrations."); - println!(" Run 'vespertide diff' to see details."); + println!( + "{} {}", + "Status:".bright_cyan().bold(), + "Schema differs from applied migrations.".bright_yellow() + ); + println!( + " {} {} {}", + "Run".bright_white(), + "'vespertide diff'".bright_cyan().bold(), + "to see details.".bright_white() + ); } } else if current_models.is_empty() { - println!("Status: No models or migrations found."); + println!( + "{} {}", + "Status:".bright_cyan().bold(), + "No models or migrations found.".bright_yellow() + ); } else { - println!("Status: Models exist but no migrations have been applied."); - println!(" Run 'vespertide revision -m \"initial\"' to create the first migration."); + println!( + "{} {}", + "Status:".bright_cyan().bold(), + "Models exist but no migrations have been applied.".bright_yellow() + ); + println!( + " {} {} {}", + "Run".bright_white(), + "'vespertide revision -m \"initial\"'".bright_cyan().bold(), + "to create the first migration.".bright_white() + ); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::{fs, path::PathBuf}; + use tempfile::tempdir; + use vespertide_config::VespertideConfig; + use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan, TableDef}; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + fn write_config() -> VespertideConfig { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + 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(); + let table = TableDef { + name: name.to_string(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![], + indexes: vec![], + }; + let path = models_dir.join(format!("{name}.json")); + fs::write(path, serde_json::to_string_pretty(&table).unwrap()).unwrap(); + } + + fn write_migration(cfg: &VespertideConfig) { + fs::create_dir_all(cfg.migrations_dir()).unwrap(); + let plan = MigrationPlan { + comment: Some("init".into()), + created_at: Some("2024-01-01T00:00:00Z".into()), + version: 1, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![], + }], + }; + let path = cfg.migrations_dir().join("0001_init.json"); + fs::write(path, serde_json::to_string_pretty(&plan).unwrap()).unwrap(); + } + + #[test] + #[serial] + fn cmd_status_with_matching_schema() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + write_model("users"); + write_migration(&cfg); + + cmd_status().unwrap(); + } + + #[test] + #[serial] + 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(); + } + + #[test] + #[serial] + 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(); + } + + #[test] + #[serial] + fn cmd_status_differs_prints_diff_hint() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + + let cfg = write_config(); + write_model("users"); + // add another model to differ from baseline + write_model("posts"); + write_migration(&cfg); // baseline only has users + + cmd_status().unwrap(); + } +} diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index d2f112f..002f0b0 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -35,9 +35,8 @@ pub fn load_models(config: &VespertideConfig) -> Result> { if path.is_file() { let ext = path.extension().and_then(|s| s.to_str()); if ext == Some("json") || ext == Some("yaml") || ext == Some("yml") { - let content = fs::read_to_string(&path).with_context(|| { - format!("read model file: {}", path.display()) - })?; + let content = fs::read_to_string(&path) + .with_context(|| format!("read model file: {}", path.display()))?; let table: TableDef = if ext == Some("json") { serde_json::from_str(&content) @@ -54,8 +53,7 @@ pub fn load_models(config: &VespertideConfig) -> Result> { // Validate schema integrity before returning if !tables.is_empty() { - validate_schema(&tables) - .map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; + validate_schema(&tables).map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; } Ok(tables) @@ -77,9 +75,8 @@ pub fn load_migrations(config: &VespertideConfig) -> Result> if path.is_file() { let ext = path.extension().and_then(|s| s.to_str()); if ext == Some("json") || ext == Some("yaml") || ext == Some("yml") { - let content = fs::read_to_string(&path).with_context(|| { - format!("read migration file: {}", path.display()) - })?; + let content = fs::read_to_string(&path) + .with_context(|| format!("read migration file: {}", path.display()))?; let plan: MigrationPlan = if ext == Some("json") { serde_json::from_str(&content) @@ -99,17 +96,6 @@ pub fn load_migrations(config: &VespertideConfig) -> Result> Ok(plans) } -#[allow(dead_code)] -/// Generate a migration filename from version and optional comment using defaults. -pub fn migration_filename(version: u32, comment: Option<&str>) -> String { - migration_filename_with_format_and_pattern( - version, - comment, - FileFormat::Json, - vespertide_config::default_migration_filename_pattern().as_str(), - ) -} - /// Generate a migration filename from version and optional comment with format and pattern. pub fn migration_filename_with_format_and_pattern( version: u32, @@ -134,7 +120,13 @@ fn sanitize_comment(comment: Option<&str>) -> String { .map(|c| { c.to_lowercase() .chars() - .map(|ch| if ch.is_alphanumeric() || ch == ' ' { ch } else { '_' }) + .map(|ch| { + if ch.is_alphanumeric() || ch == ' ' { + ch + } else { + '_' + } + }) .collect::() .split_whitespace() .collect::>() @@ -200,3 +192,137 @@ fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) - } } +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + use vespertide_core::{ColumnDef, ColumnType}; + + struct CwdGuard { + original: PathBuf, + } + + impl CwdGuard { + fn new(dir: &PathBuf) -> Self { + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + fn write_config() { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + fs::write("vespertide.json", text).unwrap(); + } + + #[test] + #[serial] + fn load_config_missing_file_errors() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + let err = load_config().unwrap_err(); + assert!(err.to_string().contains("vespertide.json not found")); + } + + #[test] + #[serial] + fn load_models_reads_yaml_and_validates() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models").unwrap(); + let table = TableDef { + name: "users".into(), + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Integer, + nullable: false, + default: None, + }], + constraints: vec![], + indexes: vec![], + }; + fs::write("models/users.yaml", serde_yaml::to_string(&table).unwrap()).unwrap(); + + let models = load_models(&VespertideConfig::default()).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].name, "users"); + } + + #[test] + #[serial] + fn load_migrations_reads_yaml_and_sorts() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("migrations").unwrap(); + let plan1 = MigrationPlan { + comment: Some("first".into()), + created_at: None, + version: 2, + actions: vec![], + }; + let plan0 = MigrationPlan { + comment: Some("zero".into()), + created_at: None, + version: 1, + actions: vec![], + }; + fs::write( + "migrations/0002_first.yaml", + serde_yaml::to_string(&plan1).unwrap(), + ) + .unwrap(); + fs::write( + "migrations/0001_zero.yaml", + serde_yaml::to_string(&plan0).unwrap(), + ) + .unwrap(); + + let plans = load_migrations(&VespertideConfig::default()).unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].version, 1); + assert_eq!(plans[1].version, 2); + } + + #[test] + fn migration_filename_respects_format_and_sanitizes_comment() { + let name = migration_filename_with_format_and_pattern( + 5, + Some("Hello! World"), + FileFormat::Yml, + "%04v_%m", + ); + assert_eq!(name, "0005_hello__world.yml"); + } + + #[test] + fn migration_filename_handles_zero_width_and_trim() { + // width 0 falls back to default version and trailing separators are trimmed + let name = migration_filename_with_format_and_pattern(3, None, FileFormat::Json, "%0v__"); + assert_eq!(name, "0003.json"); + } + + #[test] + fn migration_filename_replaces_version_directly() { + let name = migration_filename_with_format_and_pattern(12, None, FileFormat::Json, "%v"); + assert_eq!(name, "0012.json"); + } + + #[test] + fn migration_filename_uses_default_when_comment_only_and_empty() { + let name = migration_filename_with_format_and_pattern(7, None, FileFormat::Json, "%m"); + assert_eq!(name, "0007.json"); + } +} diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index f05d9ce..193cc26 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -41,10 +41,6 @@ impl Default for VespertideConfig { } impl VespertideConfig { - pub fn new() -> Self { - Self::default() - } - /// Path where model definitions are stored. pub fn models_dir(&self) -> &Path { &self.models_dir @@ -80,4 +76,3 @@ impl VespertideConfig { &self.migration_filename_pattern } } - diff --git a/crates/vespertide-config/src/file_format.rs b/crates/vespertide-config/src/file_format.rs index c5f8ae8..7328b15 100644 --- a/crates/vespertide-config/src/file_format.rs +++ b/crates/vespertide-config/src/file_format.rs @@ -4,15 +4,20 @@ use serde::{Deserialize, Serialize}; /// Supported file formats for generated artifacts. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum FileFormat { + #[default] Json, Yaml, Yml, } -impl Default for FileFormat { - fn default() -> Self { - FileFormat::Json +#[cfg(test)] +mod tests { + use super::FileFormat; + + #[test] + fn default_is_json() { + assert_eq!(FileFormat::default(), FileFormat::Json); } } - diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 6809bee..494e982 100644 --- a/crates/vespertide-config/src/lib.rs +++ b/crates/vespertide-config/src/lib.rs @@ -2,7 +2,7 @@ pub mod config; pub mod file_format; pub mod name_case; -pub use config::{default_migration_filename_pattern, VespertideConfig}; +pub use config::{VespertideConfig, default_migration_filename_pattern}; pub use file_format::FileFormat; pub use name_case::NameCase; diff --git a/crates/vespertide-config/src/name_case.rs b/crates/vespertide-config/src/name_case.rs index 8302045..dd608c1 100644 --- a/crates/vespertide-config/src/name_case.rs +++ b/crates/vespertide-config/src/name_case.rs @@ -25,4 +25,3 @@ impl NameCase { matches!(self, NameCase::Pascal) } } - diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index 7c20b6d..172d6a6 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -1,8 +1,8 @@ use crate::schema::{ ColumnDef, ColumnName, ColumnType, IndexDef, IndexName, TableConstraint, TableName, }; -use serde::{Deserialize, Serialize}; use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] diff --git a/crates/vespertide-core/src/schema/index.rs b/crates/vespertide-core/src/schema/index.rs index b532fb2..b550a55 100644 --- a/crates/vespertide-core/src/schema/index.rs +++ b/crates/vespertide-core/src/schema/index.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use crate::schema::names::{ColumnName, IndexName}; @@ -10,4 +10,3 @@ pub struct IndexDef { pub columns: Vec, pub unique: bool, } - diff --git a/crates/vespertide-core/src/schema/mod.rs b/crates/vespertide-core/src/schema/mod.rs index 5bb8c8f..e960622 100644 --- a/crates/vespertide-core/src/schema/mod.rs +++ b/crates/vespertide-core/src/schema/mod.rs @@ -11,4 +11,3 @@ pub use index::IndexDef; pub use names::{ColumnName, IndexName, TableName}; pub use reference::ReferenceAction; pub use table::TableDef; - diff --git a/crates/vespertide-core/src/schema/names.rs b/crates/vespertide-core/src/schema/names.rs index d371cd0..47c5889 100644 --- a/crates/vespertide-core/src/schema/names.rs +++ b/crates/vespertide-core/src/schema/names.rs @@ -1,4 +1,3 @@ pub type TableName = String; pub type ColumnName = String; pub type IndexName = String; - diff --git a/crates/vespertide-core/src/schema/reference.rs b/crates/vespertide-core/src/schema/reference.rs index 17bc63a..5dcdda3 100644 --- a/crates/vespertide-core/src/schema/reference.rs +++ b/crates/vespertide-core/src/schema/reference.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum ReferenceAction { @@ -9,4 +9,3 @@ pub enum ReferenceAction { SetDefault, NoAction, } - diff --git a/crates/vespertide-core/src/schema/table.rs b/crates/vespertide-core/src/schema/table.rs index 353f4e0..fadbeac 100644 --- a/crates/vespertide-core/src/schema/table.rs +++ b/crates/vespertide-core/src/schema/table.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use crate::schema::{ column::ColumnDef, constraint::TableConstraint, index::IndexDef, names::TableName, diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index 9c2e806..a1ce1e2 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -28,9 +28,10 @@ pub fn apply_action( let before = schema.len(); schema.retain(|t| t.name != *table); if schema.len() == before { - return Err(PlannerError::TableNotFound(table.clone())); + Err(PlannerError::TableNotFound(table.clone())) + } else { + Ok(()) } - Ok(()) } MigrationAction::AddColumn { table, @@ -42,13 +43,14 @@ pub fn apply_action( .find(|t| t.name == *table) .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?; if tbl.columns.iter().any(|c| c.name == column.name) { - return Err(PlannerError::ColumnExists( + Err(PlannerError::ColumnExists( table.clone(), column.name.clone(), - )); + )) + } else { + tbl.columns.push(column.clone()); + Ok(()) } - tbl.columns.push(column.clone()); - Ok(()) } MigrationAction::RenameColumn { table, from, to } => { let tbl = schema @@ -73,11 +75,12 @@ pub fn apply_action( let before = tbl.columns.len(); tbl.columns.retain(|c| c.name != *column); if tbl.columns.len() == before { - return Err(PlannerError::ColumnNotFound(table.clone(), column.clone())); + Err(PlannerError::ColumnNotFound(table.clone(), column.clone())) + } else { + drop_column_from_constraints(&mut tbl.constraints, column); + drop_column_from_indexes(&mut tbl.indexes, column); + Ok(()) } - drop_column_from_constraints(&mut tbl.constraints, column); - drop_column_from_indexes(&mut tbl.indexes, column); - Ok(()) } MigrationAction::ModifyColumnType { table, @@ -112,20 +115,22 @@ pub fn apply_action( let before = tbl.indexes.len(); tbl.indexes.retain(|i| i.name != *name); if tbl.indexes.len() == before { - return Err(PlannerError::IndexNotFound(table.clone(), name.clone())); + Err(PlannerError::IndexNotFound(table.clone(), name.clone())) + } else { + Ok(()) } - Ok(()) } MigrationAction::RenameTable { from, to } => { if schema.iter().any(|t| t.name == *to) { - return Err(PlannerError::TableExists(to.clone())); + Err(PlannerError::TableExists(to.clone())) + } else { + let tbl = schema + .iter_mut() + .find(|t| t.name == *from) + .ok_or_else(|| PlannerError::TableNotFound(from.clone()))?; + tbl.name = to.clone(); + Ok(()) } - let tbl = schema - .iter_mut() - .find(|t| t.name == *from) - .ok_or_else(|| PlannerError::TableNotFound(from.clone()))?; - tbl.name = to.clone(); - Ok(()) } } } @@ -311,6 +316,17 @@ mod tests { }, ErrKind::IndexNotFound )] + #[case( + vec![ + table("old", vec![col("id", ColumnType::Integer)], vec![], vec![]), + table("new", vec![col("id", ColumnType::Integer)], vec![], vec![]), + ], + MigrationAction::RenameTable { + from: "old".into(), + to: "new".into() + }, + ErrKind::TableExists + )] fn apply_action_reports_errors( #[case] mut schema: Vec, #[case] action: MigrationAction, @@ -319,5 +335,287 @@ mod tests { let err = apply_action(&mut schema, &action).unwrap_err(); assert_err_kind(err, expected); } -} + fn idx(name: &str, columns: Vec<&str>, unique: bool) -> IndexDef { + IndexDef { + name: name.to_string(), + columns: columns.into_iter().map(|s| s.to_string()).collect(), + unique, + } + } + + #[derive(Clone)] + struct SuccessCase { + initial: Vec, + actions: Vec, + expected: Vec, + } + + #[rstest] + #[case(SuccessCase { + initial: vec![], + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![col("id", ColumnType::Integer)], + constraints: vec![], + }, + MigrationAction::DeleteTable { + table: "users".into(), + }, + ], + expected: vec![], + })] + #[case(SuccessCase { + initial: vec![table( + "users", + vec![ + col("id", ColumnType::Integer), + col("old", ColumnType::Text), + col("ref_id", ColumnType::Integer) + ], + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Unique { + name: Some("u_old".into()), + columns: vec!["old".into()], + }, + TableConstraint::ForeignKey { + name: Some("fk_old".into()), + columns: vec!["old".into()], + ref_table: "ref_table".into(), + ref_columns: vec!["ref_id".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Check { + name: None, + expr: "old IS NOT NULL".into(), + }, + ], + vec![ + idx("idx_old", vec!["old"], false), + idx("idx_ref", vec!["ref_id"], false), + ], + )], + actions: vec![ + MigrationAction::AddColumn { + table: "users".into(), + column: col("new_col", ColumnType::Boolean), + fill_with: None, + }, + MigrationAction::RenameColumn { + table: "users".into(), + from: "ref_id".into(), + to: "renamed".into(), + }, + ], + expected: vec![table( + "users", + vec![ + col("id", ColumnType::Integer), + col("old", ColumnType::Text), + col("renamed", ColumnType::Integer), + col("new_col", ColumnType::Boolean) + ], + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Unique { + name: Some("u_old".into()), + columns: vec!["old".into()], + }, + TableConstraint::ForeignKey { + name: Some("fk_old".into()), + columns: vec!["old".into()], + ref_table: "ref_table".into(), + ref_columns: vec!["renamed".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Check { + name: None, + expr: "old IS NOT NULL".into(), + }, + ], + vec![ + idx("idx_old", vec!["old"], false), + idx("idx_ref", vec!["renamed"], false), + ], + )], + })] + #[case(SuccessCase { + initial: vec![table( + "users", + vec![col("id", ColumnType::Integer), col("old", ColumnType::Text)], + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Unique { + name: Some("u_old".into()), + columns: vec!["old".into()], + }, + TableConstraint::ForeignKey { + name: Some("fk_old".into()), + columns: vec!["old".into()], + ref_table: "ref_table".into(), + ref_columns: vec!["old".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Check { + name: None, + expr: "old IS NOT NULL".into(), + }, + ], + vec![idx("idx_old", vec!["old"], false)], + )], + actions: vec![MigrationAction::DeleteColumn { + table: "users".into(), + column: "old".into(), + }], + expected: vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Check { + name: None, + expr: "old IS NOT NULL".into(), + }, + ], + vec![], + )], + })] + #[case(SuccessCase { + initial: vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + actions: vec![ + MigrationAction::ModifyColumnType { + table: "users".into(), + column: "id".into(), + new_type: ColumnType::Text, + }, + MigrationAction::AddIndex { + table: "users".into(), + index: idx("idx_id", vec!["id"], true), + }, + MigrationAction::RemoveIndex { + table: "users".into(), + name: "idx_id".into(), + }, + ], + expected: vec![table( + "users", + vec![col("id", ColumnType::Text)], + vec![], + vec![], + )], + })] + #[case(SuccessCase { + initial: vec![table( + "old", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + actions: vec![MigrationAction::RenameTable { + from: "old".into(), + to: "new".into(), + }], + expected: vec![table( + "new", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + })] + fn apply_action_success_cases(#[case] case: SuccessCase) { + let mut schema = case.initial; + for action in case.actions { + apply_action(&mut schema, &action).unwrap(); + } + assert_eq!(schema, case.expected); + } + + #[rstest] + #[case( + vec![ + TableConstraint::PrimaryKey(vec!["id".into(), "old".into()]), + TableConstraint::Unique { + name: None, + columns: vec!["old".into(), "keep".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["old".into()], + ref_table: "ref".into(), + ref_columns: vec!["old".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Check { + name: None, + expr: "old > 0".into(), + }, + ], + vec![idx("idx_old", vec!["old", "keep"], false)], + "old", + "new", + vec![ + TableConstraint::PrimaryKey(vec!["id".into(), "new".into()]), + TableConstraint::Unique { + name: None, + columns: vec!["new".into(), "keep".into()], + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["new".into()], + ref_table: "ref".into(), + ref_columns: vec!["new".into()], + on_delete: None, + on_update: None, + }, + TableConstraint::Check { + name: None, + expr: "old > 0".into(), + }, + ], + vec![idx("idx_old", vec!["new", "keep"], false)] + )] + #[case( + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Check { + name: None, + expr: "id > 0".into(), + }, + ], + vec![idx("idx_id", vec!["id"], false)], + "missing", + "new", + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Check { + name: None, + expr: "id > 0".into(), + }, + ], + vec![idx("idx_id", vec!["id"], false)] + )] + fn rename_helpers_update_constraints_and_indexes( + #[case] mut constraints: Vec, + #[case] mut indexes: Vec, + #[case] from: &str, + #[case] to: &str, + #[case] expected_constraints: Vec, + #[case] expected_indexes: Vec, + ) { + rename_column_in_constraints(&mut constraints, from, to); + rename_column_in_indexes(&mut indexes, from, to); + assert_eq!(constraints, expected_constraints); + assert_eq!(indexes, expected_indexes); + } +} diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 29738f0..036213b 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -47,14 +47,14 @@ pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result, #[case] to_schema: Vec, @@ -240,4 +325,3 @@ mod tests { assert_eq!(plan.actions, expected_actions); } } - diff --git a/crates/vespertide-planner/src/error.rs b/crates/vespertide-planner/src/error.rs index 1caed91..566e62c 100644 --- a/crates/vespertide-planner/src/error.rs +++ b/crates/vespertide-planner/src/error.rs @@ -25,4 +25,3 @@ pub enum PlannerError { #[error("constraint has empty column list: {0}.{1}")] EmptyConstraintColumns(String, String), } - diff --git a/crates/vespertide-planner/src/lib.rs b/crates/vespertide-planner/src/lib.rs index d610963..433722b 100644 --- a/crates/vespertide-planner/src/lib.rs +++ b/crates/vespertide-planner/src/lib.rs @@ -5,9 +5,9 @@ pub mod plan; pub mod schema; pub mod validate; +pub use apply::apply_action; +pub use diff::diff_schemas; pub use error::PlannerError; pub use plan::plan_next_migration; pub use schema::schema_from_plans; -pub use diff::diff_schemas; -pub use apply::apply_action; pub use validate::validate_schema; diff --git a/crates/vespertide-planner/src/plan.rs b/crates/vespertide-planner/src/plan.rs index 27f6c47..985d424 100644 --- a/crates/vespertide-planner/src/plan.rs +++ b/crates/vespertide-planner/src/plan.rs @@ -82,4 +82,3 @@ mod tests { )); } } - diff --git a/crates/vespertide-planner/src/schema.rs b/crates/vespertide-planner/src/schema.rs index 1acec4c..78174af 100644 --- a/crates/vespertide-planner/src/schema.rs +++ b/crates/vespertide-planner/src/schema.rs @@ -1,7 +1,7 @@ use vespertide_core::{MigrationPlan, TableDef}; -use crate::error::PlannerError; use crate::apply::apply_action; +use crate::error::PlannerError; /// Derive a schema snapshot from existing migration plans. pub fn schema_from_plans(plans: &[MigrationPlan]) -> Result, PlannerError> { @@ -154,4 +154,3 @@ mod tests { assert_eq!(users, &expected_users); } } - diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index 680f4ed..90289ef 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -153,7 +153,11 @@ fn validate_constraint( if columns.len() != ref_columns.len() { return Err(PlannerError::ForeignKeyColumnNotFound( table_name.to_string(), - format!("column count mismatch: {} != {}", columns.len(), ref_columns.len()), + format!( + "column count mismatch: {} != {}", + columns.len(), + ref_columns.len() + ), ref_table.clone(), "".to_string(), )); @@ -195,6 +199,7 @@ fn validate_index( #[cfg(test)] mod tests { use super::*; + use rstest::rstest; use vespertide_core::{ColumnDef, ColumnType, IndexDef, TableConstraint}; fn col(name: &str, ty: ColumnType) -> ColumnDef { @@ -220,30 +225,49 @@ mod tests { } } - #[test] - fn validate_schema_accepts_valid_schema() { - let schema = vec![table( + fn is_duplicate(err: &PlannerError) -> bool { + matches!(err, PlannerError::DuplicateTableName(_)) + } + + fn is_fk_table(err: &PlannerError) -> bool { + matches!(err, PlannerError::ForeignKeyTableNotFound(_, _, _)) + } + + fn is_fk_column(err: &PlannerError) -> bool { + matches!(err, PlannerError::ForeignKeyColumnNotFound(_, _, _, _)) + } + + fn is_index_column(err: &PlannerError) -> bool { + matches!(err, PlannerError::IndexColumnNotFound(_, _, _)) + } + + fn is_constraint_column(err: &PlannerError) -> bool { + matches!(err, PlannerError::ConstraintColumnNotFound(_, _, _)) + } + + fn is_empty_columns(err: &PlannerError) -> bool { + matches!(err, PlannerError::EmptyConstraintColumns(_, _)) + } + + #[rstest] + #[case::valid_schema( + vec![table( "users", vec![col("id", ColumnType::Integer)], vec![TableConstraint::PrimaryKey(vec!["id".into()])], vec![], - )]; - assert!(validate_schema(&schema).is_ok()); - } - - #[test] - fn validate_schema_rejects_duplicate_table_names() { - let schema = vec![ + )], + None + )] + #[case::duplicate_table( + vec![ table("users", vec![col("id", ColumnType::Integer)], vec![], vec![]), table("users", vec![col("id", ColumnType::Integer)], vec![], vec![]), - ]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::DuplicateTableName(_))); - } - - #[test] - fn validate_schema_rejects_foreign_key_to_nonexistent_table() { - let schema = vec![table( + ], + Some(is_duplicate as fn(&PlannerError) -> bool) + )] + #[case::fk_missing_table( + vec![table( "users", vec![col("id", ColumnType::Integer)], vec![TableConstraint::ForeignKey { @@ -255,14 +279,11 @@ mod tests { on_update: None, }], vec![], - )]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::ForeignKeyTableNotFound(_, _, _))); - } - - #[test] - fn validate_schema_rejects_foreign_key_to_nonexistent_column() { - let schema = vec![ + )], + Some(is_fk_table as fn(&PlannerError) -> bool) + )] + #[case::fk_missing_column( + vec![ table("posts", vec![col("id", ColumnType::Integer)], vec![], vec![]), table( "users", @@ -277,14 +298,30 @@ mod tests { }], vec![], ), - ]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::ForeignKeyColumnNotFound(_, _, _, _))); - } - - #[test] - fn validate_schema_accepts_valid_foreign_key() { - let schema = vec![ + ], + Some(is_fk_column as fn(&PlannerError) -> bool) + )] + #[case::fk_local_missing_column( + vec![ + table("posts", vec![col("id", ColumnType::Integer)], vec![], vec![]), + table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::ForeignKey { + name: None, + columns: vec!["missing".into()], + ref_table: "posts".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + vec![], + ), + ], + Some(is_constraint_column as fn(&PlannerError) -> bool) + )] + #[case::fk_valid( + vec![ table( "posts", vec![col("id", ColumnType::Integer)], @@ -304,13 +341,11 @@ mod tests { }], vec![], ), - ]; - assert!(validate_schema(&schema).is_ok()); - } - - #[test] - fn validate_schema_rejects_index_with_nonexistent_column() { - let schema = vec![table( + ], + None + )] + #[case::index_missing_column( + vec![table( "users", vec![col("id", ColumnType::Integer)], vec![], @@ -319,38 +354,53 @@ mod tests { columns: vec!["nonexistent".into()], unique: false, }], - )]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::IndexColumnNotFound(_, _, _))); - } - - #[test] - fn validate_schema_rejects_constraint_with_nonexistent_column() { - let schema = vec![table( + )], + Some(is_index_column as fn(&PlannerError) -> bool) + )] + #[case::constraint_missing_column( + vec![table( "users", vec![col("id", ColumnType::Integer)], vec![TableConstraint::PrimaryKey(vec!["nonexistent".into()])], vec![], - )]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::ConstraintColumnNotFound(_, _, _))); - } - - #[test] - fn validate_schema_rejects_empty_primary_key() { - let schema = vec![table( + )], + Some(is_constraint_column as fn(&PlannerError) -> bool) + )] + #[case::unique_empty_columns( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::Unique { + name: Some("u".into()), + columns: vec![], + }], + vec![], + )], + Some(is_empty_columns as fn(&PlannerError) -> bool) + )] + #[case::unique_missing_column( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::Unique { + name: None, + columns: vec!["missing".into()], + }], + vec![], + )], + Some(is_constraint_column as fn(&PlannerError) -> bool) + )] + #[case::empty_primary_key( + vec![table( "users", vec![col("id", ColumnType::Integer)], vec![TableConstraint::PrimaryKey(vec![])], vec![], - )]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::EmptyConstraintColumns(_, _))); - } - - #[test] - fn validate_schema_rejects_foreign_key_column_count_mismatch() { - let schema = vec![ + )], + Some(is_empty_columns as fn(&PlannerError) -> bool) + )] + #[case::fk_column_count_mismatch( + vec![ table( "posts", vec![col("id", ColumnType::Integer)], @@ -370,9 +420,98 @@ mod tests { }], vec![], ), - ]; - let err = validate_schema(&schema).unwrap_err(); - assert!(matches!(err, PlannerError::ForeignKeyColumnNotFound(_, _, _, _))); + ], + Some(is_fk_column as fn(&PlannerError) -> bool) + )] + #[case::fk_empty_columns( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::ForeignKey { + name: None, + columns: vec![], + ref_table: "posts".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + vec![], + )], + Some(is_empty_columns as fn(&PlannerError) -> bool) + )] + #[case::fk_empty_ref_columns( + vec![ + table( + "posts", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + ), + table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::ForeignKey { + name: None, + columns: vec!["id".into()], + ref_table: "posts".into(), + ref_columns: vec![], + on_delete: None, + on_update: None, + }], + vec![], + ), + ], + Some(is_empty_columns as fn(&PlannerError) -> bool) + )] + #[case::index_empty_columns( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![IndexDef { + name: "idx".into(), + columns: vec![], + unique: false, + }], + )], + Some(is_empty_columns as fn(&PlannerError) -> bool) + )] + #[case::index_valid( + vec![table( + "users", + vec![col("id", ColumnType::Integer), col("name", ColumnType::Text)], + vec![], + vec![IndexDef { + name: "idx_name".into(), + columns: vec!["name".into()], + unique: false, + }], + )], + None + )] + #[case::check_constraint_ok( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![TableConstraint::Check { + name: Some("ck".into()), + expr: "id > 0".into(), + }], + vec![], + )], + None + )] + fn validate_schema_cases( + #[case] schema: Vec, + #[case] expected_err: Option bool>, + ) { + let result = validate_schema(&schema); + match expected_err { + None => assert!(result.is_ok()), + Some(pred) => { + let err = result.unwrap_err(); + assert!(pred(&err), "unexpected error: {:?}", err); + } + } } } - diff --git a/crates/vespertide-query/Cargo.toml b/crates/vespertide-query/Cargo.toml index ad7b646..f004292 100644 --- a/crates/vespertide-query/Cargo.toml +++ b/crates/vespertide-query/Cargo.toml @@ -11,3 +11,6 @@ description = "Converts migration actions into PostgreSQL SQL statements with bi [dependencies] vespertide-core = { workspace = true } thiserror = "2" + +[dev-dependencies] +rstest = "0.26" diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 3ac5e46..235afc1 100644 --- a/crates/vespertide-query/src/builder.rs +++ b/crates/vespertide-query/src/builder.rs @@ -1,7 +1,7 @@ use vespertide_core::MigrationPlan; use crate::error::QueryError; -use crate::sql::{build_action_queries, BuiltQuery}; +use crate::sql::{BuiltQuery, build_action_queries}; pub fn build_plan_queries(plan: &MigrationPlan) -> Result, QueryError> { let mut queries: Vec = Vec::new(); @@ -11,3 +11,91 @@ pub fn build_plan_queries(plan: &MigrationPlan) -> Result, Query Ok(queries) } +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan}; + + fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: true, + default: None, + } + } + + #[rstest] + #[case::empty( + MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![], + }, + vec![] + )] + #[case::single_action( + MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::DeleteTable { + table: "users".into(), + }], + }, + vec![ + ("DROP TABLE $1;".to_string(), vec!["users".to_string()]) + ] + )] + #[case::multiple_actions( + MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![col("id", ColumnType::Integer)], + constraints: vec![], + }, + MigrationAction::DeleteTable { + table: "posts".into(), + }, + ], + }, + vec![ + ( + "CREATE TABLE $1 ($2 INTEGER);".to_string(), + vec!["users".to_string(), "id".to_string()] + ), + ( + "DROP TABLE $1;".to_string(), + vec!["posts".to_string()] + ), + ] + )] + fn test_build_plan_queries( + #[case] plan: MigrationPlan, + #[case] expected: Vec<(String, Vec)>, + ) { + let result = build_plan_queries(&plan).unwrap(); + assert_eq!( + result.len(), + expected.len(), + "Expected {} queries, got {}", + expected.len(), + result.len() + ); + + for (i, (expected_sql, expected_binds)) in expected.iter().enumerate() { + assert_eq!(result[i].sql, *expected_sql, "Query {} sql mismatch", i); + assert_eq!( + result[i].binds, *expected_binds, + "Query {} binds mismatch", + i + ); + } + } +} diff --git a/crates/vespertide-query/src/error.rs b/crates/vespertide-query/src/error.rs index f414b84..889ba5c 100644 --- a/crates/vespertide-query/src/error.rs +++ b/crates/vespertide-query/src/error.rs @@ -5,4 +5,3 @@ pub enum QueryError { #[error("unsupported table constraint")] UnsupportedConstraint, } - diff --git a/crates/vespertide-query/src/lib.rs b/crates/vespertide-query/src/lib.rs index 6f1f9e9..b522cbb 100644 --- a/crates/vespertide-query/src/lib.rs +++ b/crates/vespertide-query/src/lib.rs @@ -4,4 +4,4 @@ pub mod sql; pub use builder::build_plan_queries; pub use error::QueryError; -pub use sql::{build_action_queries, BuiltQuery}; +pub use sql::{BuiltQuery, build_action_queries}; diff --git a/crates/vespertide-query/src/sql.rs b/crates/vespertide-query/src/sql.rs index d4909b3..a0e4b03 100644 --- a/crates/vespertide-query/src/sql.rs +++ b/crates/vespertide-query/src/sql.rs @@ -335,3 +335,498 @@ fn reference_action_sql( } } +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use vespertide_core::{ + ColumnDef, ColumnType, IndexDef, MigrationAction, ReferenceAction, TableConstraint, + }; + + fn col(name: &str, ty: ColumnType) -> ColumnDef { + ColumnDef { + name: name.to_string(), + r#type: ty, + nullable: true, + default: None, + } + } + + #[rstest] + #[case( + vec!["test"], + vec!["$1"], + vec!["test".to_string()] + )] + #[case( + vec!["test", "test2"], + vec!["$1", "$2"], + vec!["test".to_string(), "test2".to_string()] + )] + fn test_bind( + #[case] inputs: Vec<&str>, + #[case] expected_placeholders: Vec<&str>, + #[case] expected_binds: Vec, + ) { + let mut binds = Vec::new(); + for (i, input) in inputs.iter().enumerate() { + let placeholder = bind(&mut binds, *input); + assert_eq!(placeholder, expected_placeholders[i]); + } + assert_eq!(binds, expected_binds); + } + + #[rstest] + #[case::create_table( + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ + col("id", ColumnType::Integer), + col("name", ColumnType::Text), + ], + constraints: vec![TableConstraint::PrimaryKey(vec!["id".into()])], + }, + vec![( + "CREATE TABLE $1 ($2 INTEGER, $3 TEXT, PRIMARY KEY ($4));".to_string(), + vec!["users".to_string(), "id".to_string(), "name".to_string(), "id".to_string()], + )] + )] + #[case::delete_table( + MigrationAction::DeleteTable { + table: "users".into(), + }, + vec![("DROP TABLE $1;".to_string(), vec!["users".to_string()])] + )] + #[case::add_column_nullable( + MigrationAction::AddColumn { + table: "users".into(), + column: col("email", ColumnType::Text), + fill_with: None, + }, + vec![( + "ALTER TABLE $1 ADD COLUMN $2 TEXT;".to_string(), + vec!["users".to_string(), "email".to_string()], + )] + )] + #[case::add_column_not_null_with_default( + { + let mut c = col("email", ColumnType::Text); + c.nullable = false; + c.default = Some("''".to_string()); + MigrationAction::AddColumn { + table: "users".into(), + column: c, + fill_with: None, + } + }, + vec![( + "ALTER TABLE $1 ADD COLUMN $2 TEXT NOT NULL DEFAULT $3;".to_string(), + vec!["users".to_string(), "email".to_string(), "''".to_string()], + )] + )] + #[case::add_column_not_null_with_fill( + { + let mut c = col("email", ColumnType::Text); + c.nullable = false; + MigrationAction::AddColumn { + table: "users".into(), + column: c, + fill_with: Some("test@example.com".to_string()), + } + }, + vec![ + ( + "ALTER TABLE $1 ADD COLUMN $2 TEXT;".to_string(), + vec!["users".to_string(), "email".to_string()], + ), + ( + "UPDATE $1 SET $2 = $3;".to_string(), + vec!["users".to_string(), "email".to_string(), "test@example.com".to_string()], + ), + ( + "ALTER TABLE $1 ALTER COLUMN $2 SET NOT NULL;".to_string(), + vec!["users".to_string(), "email".to_string()], + ), + ] + )] + #[case::add_column_not_null_without_default_without_fill( + { + let mut c = col("email", ColumnType::Text); + c.nullable = false; + MigrationAction::AddColumn { + table: "users".into(), + column: c, + fill_with: None, + } + }, + vec![( + "ALTER TABLE $1 ADD COLUMN $2 TEXT NOT NULL;".to_string(), + vec!["users".to_string(), "email".to_string()], + )] + )] + #[case::rename_column( + MigrationAction::RenameColumn { + table: "users".into(), + from: "old_name".into(), + to: "new_name".into(), + }, + vec![( + "ALTER TABLE $1 RENAME COLUMN $2 TO $3;".to_string(), + vec!["users".to_string(), "old_name".to_string(), "new_name".to_string()], + )] + )] + #[case::delete_column( + MigrationAction::DeleteColumn { + table: "users".into(), + column: "email".into(), + }, + vec![( + "ALTER TABLE $1 DROP COLUMN $2;".to_string(), + vec!["users".to_string(), "email".to_string()], + )] + )] + #[case::modify_column_type( + MigrationAction::ModifyColumnType { + table: "users".into(), + column: "age".into(), + new_type: ColumnType::BigInt, + }, + vec![( + "ALTER TABLE $1 ALTER COLUMN $2 TYPE BIGINT;".to_string(), + vec!["users".to_string(), "age".to_string()], + )] + )] + #[case::add_index( + MigrationAction::AddIndex { + table: "users".into(), + index: IndexDef { + name: "idx_email".into(), + columns: vec!["email".into()], + unique: false, + }, + }, + vec![( + "CREATE INDEX $2 ON $1 ($3);".to_string(), + vec!["users".to_string(), "idx_email".to_string(), "email".to_string()], + )] + )] + #[case::add_unique_index( + MigrationAction::AddIndex { + table: "users".into(), + index: IndexDef { + name: "idx_email".into(), + columns: vec!["email".into()], + unique: true, + }, + }, + vec![( + "CREATE UNIQUE INDEX $2 ON $1 ($3);".to_string(), + vec!["users".to_string(), "idx_email".to_string(), "email".to_string()], + )] + )] + #[case::add_index_multiple_columns( + MigrationAction::AddIndex { + table: "users".into(), + index: IndexDef { + name: "idx_name_email".into(), + columns: vec!["name".into(), "email".into()], + unique: false, + }, + }, + vec![( + "CREATE INDEX $2 ON $1 ($3, $4);".to_string(), + vec![ + "users".to_string(), + "idx_name_email".to_string(), + "name".to_string(), + "email".to_string(), + ], + )] + )] + #[case::remove_index( + MigrationAction::RemoveIndex { + table: "users".into(), + name: "idx_email".into(), + }, + vec![( + "DROP INDEX $1;".to_string(), + vec!["idx_email".to_string()], + )] + )] + #[case::rename_table( + MigrationAction::RenameTable { + from: "old_users".into(), + to: "new_users".into(), + }, + vec![( + "ALTER TABLE $1 RENAME TO $2;".to_string(), + vec!["old_users".to_string(), "new_users".to_string()], + )] + )] + fn test_build_action_queries( + #[case] action: MigrationAction, + #[case] expected: Vec<(String, Vec)>, + ) { + let result = build_action_queries(&action).unwrap(); + assert_eq!( + result.len(), + expected.len(), + "Expected {} queries, got {}", + expected.len(), + result.len() + ); + + for (i, (expected_sql, expected_binds)) in expected.iter().enumerate() { + assert_eq!(result[i].sql, *expected_sql, "Query {} mismatch sql", i); + assert_eq!( + result[i].binds, *expected_binds, + "Query {} mismatch binds", + i + ); + } + } + + #[rstest] + #[case::simple( + "users", + vec![col("id", ColumnType::Integer), col("name", ColumnType::Text)], + vec![TableConstraint::PrimaryKey(vec!["id".into()])], + ( + "CREATE TABLE $1 ($2 INTEGER, $3 TEXT, PRIMARY KEY ($4));".to_string(), + vec!["users".to_string(), "id".to_string(), "name".to_string(), "id".to_string()], + ) + )] + #[case::multiple_constraints( + "users", + vec![col("id", ColumnType::Integer), col("email", ColumnType::Text)], + vec![ + TableConstraint::PrimaryKey(vec!["id".into()]), + TableConstraint::Unique { + name: Some("unique_email".into()), + columns: vec!["email".into()], + }, + ], + ( + "CREATE TABLE $1 ($2 INTEGER, $3 TEXT, PRIMARY KEY ($4), CONSTRAINT $5 UNIQUE ($6));".to_string(), + vec![ + "users".to_string(), + "id".to_string(), + "email".to_string(), + "id".to_string(), + "unique_email".to_string(), + "email".to_string(), + ], + ) + )] + fn test_create_table_sql( + #[case] table: &str, + #[case] columns: Vec, + #[case] constraints: Vec, + #[case] expected: (String, Vec), + ) { + let result = create_table_sql(table, &columns, &constraints).unwrap(); + assert_eq!(result.sql, expected.0); + assert_eq!(result.binds, expected.1); + } + + #[rstest] + #[case::nullable( + col("name", ColumnType::Text), + ("$1 TEXT".to_string(), vec!["name".to_string()]) + )] + #[case::not_null( + { + let mut c = col("name", ColumnType::Text); + c.nullable = false; + c + }, + ("$1 TEXT NOT NULL".to_string(), vec!["name".to_string()]) + )] + #[case::with_default( + { + let mut c = col("name", ColumnType::Text); + c.default = Some("'default'".to_string()); + c + }, + ( + "$1 TEXT DEFAULT $2".to_string(), + vec!["name".to_string(), "'default'".to_string()], + ) + )] + fn test_column_def_sql(#[case] column: ColumnDef, #[case] expected: (String, Vec)) { + let mut binds = Vec::new(); + let result = column_def_sql(&column, &mut binds); + assert_eq!(result, expected.0); + assert_eq!(binds, expected.1); + } + + #[rstest] + #[case(ColumnType::Integer, "INTEGER")] + #[case(ColumnType::BigInt, "BIGINT")] + #[case(ColumnType::Text, "TEXT")] + #[case(ColumnType::Boolean, "BOOLEAN")] + #[case(ColumnType::Timestamp, "TIMESTAMP")] + #[case(ColumnType::Custom("VARCHAR(255)".to_string()), "VARCHAR(255)")] + fn test_column_type_sql(#[case] ty: ColumnType, #[case] expected: &str) { + assert_eq!(column_type_sql(&ty), expected); + } + + #[rstest] + #[case::primary_key_single( + TableConstraint::PrimaryKey(vec!["id".into()]), + ("PRIMARY KEY ($1)".to_string(), vec!["id".to_string()]) + )] + #[case::primary_key_multiple( + TableConstraint::PrimaryKey(vec!["id".into(), "version".into()]), + ("PRIMARY KEY ($1, $2)".to_string(), vec!["id".to_string(), "version".to_string()]) + )] + #[case::unique_without_name( + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + ("UNIQUE ($1)".to_string(), vec!["email".to_string()]) + )] + #[case::unique_with_name( + TableConstraint::Unique { + name: Some("unique_email".into()), + columns: vec!["email".into()], + }, + ( + "CONSTRAINT $1 UNIQUE ($2)".to_string(), + vec!["unique_email".to_string(), "email".to_string()], + ) + )] + #[case::foreign_key_without_name( + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ( + "FOREIGN KEY ($1) REFERENCES $3 ($2)".to_string(), + vec!["user_id".to_string(), "id".to_string(), "users".to_string()], + ) + )] + #[case::foreign_key_with_name( + TableConstraint::ForeignKey { + name: Some("fk_user".into()), + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + ( + "CONSTRAINT $1 FOREIGN KEY ($2) REFERENCES $4 ($3)".to_string(), + vec![ + "fk_user".to_string(), + "user_id".to_string(), + "id".to_string(), + "users".to_string(), + ], + ) + )] + #[case::foreign_key_with_on_delete( + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + ( + "FOREIGN KEY ($1) REFERENCES $3 ($2) ON DELETE CASCADE".to_string(), + vec!["user_id".to_string(), "id".to_string(), "users".to_string()], + ) + )] + #[case::foreign_key_with_on_update( + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: Some(ReferenceAction::Restrict), + }, + ( + "FOREIGN KEY ($1) REFERENCES $3 ($2) ON UPDATE RESTRICT".to_string(), + vec!["user_id".to_string(), "id".to_string(), "users".to_string()], + ) + )] + #[case::foreign_key_with_both_actions( + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::SetNull), + on_update: Some(ReferenceAction::SetDefault), + }, + ( + "FOREIGN KEY ($1) REFERENCES $3 ($2) ON DELETE SET NULL ON UPDATE SET DEFAULT".to_string(), + vec!["user_id".to_string(), "id".to_string(), "users".to_string()], + ) + )] + #[case::foreign_key_multiple_columns( + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into(), "tenant_id".into()], + ref_table: "user_tenants".into(), + ref_columns: vec!["user_id".into(), "tenant_id".into()], + on_delete: None, + on_update: None, + }, + ( + "FOREIGN KEY ($1, $2) REFERENCES $5 ($3, $4)".to_string(), + vec![ + "user_id".to_string(), + "tenant_id".to_string(), + "user_id".to_string(), + "tenant_id".to_string(), + "user_tenants".to_string(), + ], + ) + )] + #[case::check_without_name( + TableConstraint::Check { + name: None, + expr: "age > 0".to_string(), + }, + ("CHECK ($1)".to_string(), vec!["age > 0".to_string()]) + )] + #[case::check_with_name( + TableConstraint::Check { + name: Some("check_age".into()), + expr: "age > 0".to_string(), + }, + ( + "CONSTRAINT $1 CHECK ($2)".to_string(), + vec!["check_age".to_string(), "age > 0".to_string()], + ) + )] + fn test_table_constraint_sql( + #[case] constraint: TableConstraint, + #[case] expected: (String, Vec), + ) { + let mut binds = Vec::new(); + let result = table_constraint_sql(&constraint, &mut binds).unwrap(); + assert_eq!(result, expected.0); + assert_eq!(binds, expected.1); + } + + #[rstest] + #[case(ReferenceAction::Cascade, "CASCADE")] + #[case(ReferenceAction::Restrict, "RESTRICT")] + #[case(ReferenceAction::SetNull, "SET NULL")] + #[case(ReferenceAction::SetDefault, "SET DEFAULT")] + #[case(ReferenceAction::NoAction, "NO ACTION")] + fn test_reference_action_sql(#[case] action: ReferenceAction, #[case] expected: &str) { + let mut binds = Vec::new(); + assert_eq!(reference_action_sql(&action, &mut binds), expected); + } +} diff --git a/crates/vespertide-schema-gen/Cargo.toml b/crates/vespertide-schema-gen/Cargo.toml index f9210e3..f132644 100644 --- a/crates/vespertide-schema-gen/Cargo.toml +++ b/crates/vespertide-schema-gen/Cargo.toml @@ -16,3 +16,6 @@ schemars = "1.1" serde_json = "1" vespertide-core = { workspace = true } +[dev-dependencies] +tempfile = "3" + diff --git a/crates/vespertide-schema-gen/src/main.rs b/crates/vespertide-schema-gen/src/main.rs index 94a914f..fffb60b 100644 --- a/crates/vespertide-schema-gen/src/main.rs +++ b/crates/vespertide-schema-gen/src/main.rs @@ -7,7 +7,10 @@ use schemars::schema_for; use vespertide_core::{MigrationPlan, TableDef}; #[derive(Debug, Parser)] -#[command(name = "vespertide-schema-gen", about = "Emit JSON Schemas for vespertide models and migrations.")] +#[command( + name = "vespertide-schema-gen", + about = "Emit JSON Schemas for vespertide models and migrations." +)] struct Args { /// Output directory for schema files. #[arg(short = 'o', long = "out", default_value = "schemas")] @@ -16,8 +19,10 @@ struct Args { fn main() -> Result<()> { let args = Args::parse(); - let out = args.out; + run(args.out) +} +fn run(out: PathBuf) -> Result<()> { if !out.exists() { fs::create_dir_all(&out).with_context(|| format!("create dir {}", out.display()))?; } @@ -46,3 +51,88 @@ fn main() -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn run_creates_output_directory_if_not_exists() { + let temp_dir = TempDir::new().unwrap(); + let out = temp_dir.path().join("test_schemas"); + + assert!(!out.exists()); + run(out.clone()).unwrap(); + assert!(out.exists()); + } + + #[test] + fn run_generates_model_schema_file() { + let temp_dir = TempDir::new().unwrap(); + let out = temp_dir.path(); + + run(out.to_path_buf()).unwrap(); + + let model_path = out.join("model.schema.json"); + assert!(model_path.exists()); + + let content = fs::read_to_string(&model_path).unwrap(); + assert!(content.contains("TableDef")); + assert!(content.contains("ColumnDef")); + } + + #[test] + fn run_generates_migration_schema_file() { + let temp_dir = TempDir::new().unwrap(); + let out = temp_dir.path(); + + run(out.to_path_buf()).unwrap(); + + let migration_path = out.join("migration.schema.json"); + assert!(migration_path.exists()); + + let content = fs::read_to_string(&migration_path).unwrap(); + assert!(content.contains("MigrationPlan")); + assert!(content.contains("MigrationAction")); + } + + #[test] + fn run_generates_both_schema_files() { + let temp_dir = TempDir::new().unwrap(); + let out = temp_dir.path(); + + run(out.to_path_buf()).unwrap(); + + let model_path = out.join("model.schema.json"); + let migration_path = out.join("migration.schema.json"); + + assert!(model_path.exists()); + assert!(migration_path.exists()); + + // Verify files are valid JSON + let model_content = fs::read_to_string(&model_path).unwrap(); + let migration_content = fs::read_to_string(&migration_path).unwrap(); + + serde_json::from_str::(&model_content).unwrap(); + serde_json::from_str::(&migration_content).unwrap(); + } + + #[test] + fn run_works_with_existing_directory() { + let temp_dir = TempDir::new().unwrap(); + let out = temp_dir.path(); + + // Create directory first + fs::create_dir_all(&out).unwrap(); + assert!(out.exists()); + + // Should still work + run(out.to_path_buf()).unwrap(); + + let model_path = out.join("model.schema.json"); + let migration_path = out.join("migration.schema.json"); + assert!(model_path.exists()); + assert!(migration_path.exists()); + } +} diff --git a/examples/app/models/user copy.json b/examples/app/models/user copy.json deleted file mode 100644 index 8f07cd0..0000000 --- a/examples/app/models/user copy.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", - "columns": [{ - "name": "aa1", - "type": "Integer", - "nullable": false - }], - "constraints": [], - "indexes": [], - "name": "user" -} \ No newline at end of file