From 90629e11c01c6f6b9e36399433b96e66578041ed Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 14:42:44 +0900 Subject: [PATCH 01/12] Add coverage --- .github/workflows/CI.yml | 13 +- Cargo.lock | 88 +++++++++++++ crates/vespertide-schema-gen/Cargo.toml | 3 + crates/vespertide-schema-gen/src/main.rs | 151 ++++++++++++++++++++++- 4 files changed, 253 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 19f3e9a1..5079553b 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 4555b4cd..a1c4f4ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -184,6 +190,22 @@ 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", +] + +[[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" @@ -233,6 +255,18 @@ dependencies = [ "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 +347,12 @@ 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 = "log" version = "0.4.29" @@ -385,6 +425,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "ref-cast" version = "1.0.25" @@ -478,6 +524,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", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -617,6 +676,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", +] + [[package]] name = "thiserror" version = "2.0.17" @@ -757,9 +829,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" @@ -881,3 +963,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-schema-gen/Cargo.toml b/crates/vespertide-schema-gen/Cargo.toml index f9210e33..f132644b 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 94a914f7..cd6dd5f8 100644 --- a/crates/vespertide-schema-gen/src/main.rs +++ b/crates/vespertide-schema-gen/src/main.rs @@ -16,8 +16,71 @@ struct Args { fn main() -> Result<()> { let args = Args::parse(); - let out = args.out; + run(args.out) +} + +#[cfg(test)] +mod main_tests { + use super::*; + use clap::Parser; + use tempfile::TempDir; + + #[test] + fn main_parses_default_args() { + // Test with default value (simulated) + let args = Args::try_parse_from(&["vespertide-schema-gen"]).unwrap(); + assert_eq!(args.out, PathBuf::from("schemas")); + } + + #[test] + fn main_parses_custom_output_dir() { + let temp_dir = TempDir::new().unwrap(); + let custom_out = temp_dir.path().join("custom_schemas"); + + let args = Args::try_parse_from(&[ + "vespertide-schema-gen", + "--out", + custom_out.to_str().unwrap(), + ]).unwrap(); + + assert_eq!(args.out, custom_out); + } + #[test] + fn main_parses_short_output_flag() { + let temp_dir = TempDir::new().unwrap(); + let custom_out = temp_dir.path().join("short_schemas"); + + let args = Args::try_parse_from(&[ + "vespertide-schema-gen", + "-o", + custom_out.to_str().unwrap(), + ]).unwrap(); + + assert_eq!(args.out, custom_out); + } + + #[test] + fn main_integration_with_default_args() { + let temp_dir = TempDir::new().unwrap(); + let out = temp_dir.path().join("schemas"); + + // Simulate main() behavior + let args = Args::try_parse_from(&[ + "vespertide-schema-gen", + "--out", + out.to_str().unwrap(), + ]).unwrap(); + + run(args.out.clone()).unwrap(); + + assert!(out.exists()); + assert!(out.join("model.schema.json").exists()); + assert!(out.join("migration.schema.json").exists()); + } +} + +fn run(out: PathBuf) -> Result<()> { if !out.exists() { fs::create_dir_all(&out).with_context(|| format!("create dir {}", out.display()))?; } @@ -46,3 +109,89 @@ 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()); + } +} + From 8607e80c52714574424b57d160ef5fd237b64f3e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 15:05:41 +0900 Subject: [PATCH 02/12] Add schema test --- crates/vespertide-schema-gen/src/main.rs | 61 ------------------------ 1 file changed, 61 deletions(-) diff --git a/crates/vespertide-schema-gen/src/main.rs b/crates/vespertide-schema-gen/src/main.rs index cd6dd5f8..2e0e7755 100644 --- a/crates/vespertide-schema-gen/src/main.rs +++ b/crates/vespertide-schema-gen/src/main.rs @@ -19,67 +19,6 @@ fn main() -> Result<()> { run(args.out) } -#[cfg(test)] -mod main_tests { - use super::*; - use clap::Parser; - use tempfile::TempDir; - - #[test] - fn main_parses_default_args() { - // Test with default value (simulated) - let args = Args::try_parse_from(&["vespertide-schema-gen"]).unwrap(); - assert_eq!(args.out, PathBuf::from("schemas")); - } - - #[test] - fn main_parses_custom_output_dir() { - let temp_dir = TempDir::new().unwrap(); - let custom_out = temp_dir.path().join("custom_schemas"); - - let args = Args::try_parse_from(&[ - "vespertide-schema-gen", - "--out", - custom_out.to_str().unwrap(), - ]).unwrap(); - - assert_eq!(args.out, custom_out); - } - - #[test] - fn main_parses_short_output_flag() { - let temp_dir = TempDir::new().unwrap(); - let custom_out = temp_dir.path().join("short_schemas"); - - let args = Args::try_parse_from(&[ - "vespertide-schema-gen", - "-o", - custom_out.to_str().unwrap(), - ]).unwrap(); - - assert_eq!(args.out, custom_out); - } - - #[test] - fn main_integration_with_default_args() { - let temp_dir = TempDir::new().unwrap(); - let out = temp_dir.path().join("schemas"); - - // Simulate main() behavior - let args = Args::try_parse_from(&[ - "vespertide-schema-gen", - "--out", - out.to_str().unwrap(), - ]).unwrap(); - - run(args.out.clone()).unwrap(); - - assert!(out.exists()); - assert!(out.join("model.schema.json").exists()); - assert!(out.join("migration.schema.json").exists()); - } -} - fn run(out: PathBuf) -> Result<()> { if !out.exists() { fs::create_dir_all(&out).with_context(|| format!("create dir {}", out.display()))?; From eab1c2f1918c60c6b8ab3528da9998eedd2998ba Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 17:14:29 +0900 Subject: [PATCH 03/12] Add testcase --- Cargo.lock | 87 ++++- crates/vespertide-query/Cargo.toml | 3 + crates/vespertide-query/src/builder.rs | 85 +++++ crates/vespertide-query/src/sql.rs | 494 +++++++++++++++++++++++++ 4 files changed, 667 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1c4f4ec..fc483be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,12 +212,54 @@ 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" @@ -229,6 +271,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" @@ -247,9 +295,13 @@ 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", @@ -486,6 +538,18 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "rstest" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros 0.21.0", + "rustc_version", +] + [[package]] name = "rstest" version = "0.26.1" @@ -494,7 +558,25 @@ checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" dependencies = [ "futures-timer", "futures-util", - "rstest_macros", + "rstest_macros 0.26.1", +] + +[[package]] +name = "rstest_macros" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", ] [[package]] @@ -808,7 +890,7 @@ dependencies = [ name = "vespertide-planner" version = "0.1.0" dependencies = [ - "rstest", + "rstest 0.26.1", "thiserror", "vespertide-core", ] @@ -817,6 +899,7 @@ dependencies = [ name = "vespertide-query" version = "0.1.0" dependencies = [ + "rstest 0.21.0", "thiserror", "vespertide-core", ] diff --git a/crates/vespertide-query/Cargo.toml b/crates/vespertide-query/Cargo.toml index ad7b6462..ed3c819a 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.21" \ No newline at end of file diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 3ac5e46f..864818f2 100644 --- a/crates/vespertide-query/src/builder.rs +++ b/crates/vespertide-query/src/builder.rs @@ -11,3 +11,88 @@ 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/sql.rs b/crates/vespertide-query/src/sql.rs index d4909b32..0b78b8e4 100644 --- a/crates/vespertide-query/src/sql.rs +++ b/crates/vespertide-query/src/sql.rs @@ -335,3 +335,497 @@ 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); + } +} From 2c67d1112e0f0084c970be26774363c96bab9bfd Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 18:26:23 +0900 Subject: [PATCH 04/12] Add validate test coverage --- crates/vespertide-planner/src/validate.rs | 276 ++++++++++++++++------ examples/app/models/user copy.json | 11 - 2 files changed, 208 insertions(+), 79 deletions(-) delete mode 100644 examples/app/models/user copy.json diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index 680f4ede..3e876c5c 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -195,6 +195,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 +221,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 +275,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 +294,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 +337,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 +350,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 +416,103 @@ 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/examples/app/models/user copy.json b/examples/app/models/user copy.json deleted file mode 100644 index 8f07cd0b..00000000 --- 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 From 96d3631875524a9d508b6b411c3202d7c82cb8f6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 21:34:44 +0900 Subject: [PATCH 05/12] Add testcase --- Cargo.lock | 132 ++++++++++---- crates/vespertide-cli/Cargo.toml | 5 + crates/vespertide-cli/src/commands/diff.rs | 163 +++++++++++++++++- crates/vespertide-cli/src/commands/init.rs | 47 ++++- crates/vespertide-cli/src/commands/log.rs | 75 ++++++++ crates/vespertide-cli/src/commands/new.rs | 120 +++++++++++++ .../vespertide-cli/src/commands/revision.rs | 117 ++++++++++++- crates/vespertide-cli/src/commands/sql.rs | 106 +++++++++++- crates/vespertide-cli/src/commands/status.rs | 150 +++++++++++++++- crates/vespertide-cli/src/utils.rs | 22 +-- crates/vespertide-config/src/config.rs | 1 - crates/vespertide-config/src/file_format.rs | 1 - crates/vespertide-config/src/lib.rs | 2 +- crates/vespertide-config/src/name_case.rs | 1 - crates/vespertide-core/src/action.rs | 2 +- crates/vespertide-core/src/schema/index.rs | 3 +- crates/vespertide-core/src/schema/mod.rs | 1 - crates/vespertide-core/src/schema/names.rs | 1 - .../vespertide-core/src/schema/reference.rs | 3 +- crates/vespertide-core/src/schema/table.rs | 2 +- crates/vespertide-planner/src/apply.rs | 1 - crates/vespertide-planner/src/diff.rs | 86 ++++++++- crates/vespertide-planner/src/error.rs | 1 - crates/vespertide-planner/src/lib.rs | 4 +- crates/vespertide-planner/src/plan.rs | 1 - crates/vespertide-planner/src/schema.rs | 3 +- crates/vespertide-planner/src/validate.rs | 13 +- crates/vespertide-query/Cargo.toml | 2 +- crates/vespertide-query/src/builder.rs | 9 +- crates/vespertide-query/src/error.rs | 1 - crates/vespertide-query/src/lib.rs | 2 +- crates/vespertide-query/src/sql.rs | 11 +- crates/vespertide-schema-gen/src/main.rs | 36 ++-- 33 files changed, 1002 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc483be4..233a366d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,15 @@ 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" @@ -438,6 +447,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" @@ -483,6 +515,15 @@ 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" @@ -538,18 +579,6 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "rstest" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682" -dependencies = [ - "futures", - "futures-timer", - "rstest_macros 0.21.0", - "rustc_version", -] - [[package]] name = "rstest" version = "0.26.1" @@ -558,25 +587,7 @@ checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" dependencies = [ "futures-timer", "futures-util", - "rstest_macros 0.26.1", -] - -[[package]] -name = "rstest_macros" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn", - "unicode-ident", + "rstest_macros", ] [[package]] @@ -631,6 +642,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" @@ -656,6 +676,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" @@ -729,6 +761,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" @@ -741,6 +798,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" @@ -853,9 +916,12 @@ dependencies = [ "anyhow", "chrono", "clap", + "rstest", "schemars", "serde_json", "serde_yaml", + "serial_test", + "tempfile", "vespertide-config", "vespertide-core", "vespertide-planner", @@ -890,7 +956,7 @@ dependencies = [ name = "vespertide-planner" version = "0.1.0" dependencies = [ - "rstest 0.26.1", + "rstest", "thiserror", "vespertide-core", ] @@ -899,7 +965,7 @@ dependencies = [ name = "vespertide-query" version = "0.1.0" dependencies = [ - "rstest 0.21.0", + "rstest", "thiserror", "vespertide-core", ] diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 4546c6e5..9a35a445 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -21,6 +21,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 7ecce10c..e8d9de80 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -14,16 +14,14 @@ pub fn cmd_diff() -> Result<()> { 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!(); + } else { + println!("Found {} change(s) to apply:", plan.actions.len()); + 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, format_action(action)); + } } - Ok(()) } @@ -58,3 +56,152 @@ fn format_action(action: &MigrationAction) -> String { } } } + +#[cfg(test)] +mod tests { + use super::*; + 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![] }, + "Create table: users" + )] + #[case( + MigrationAction::DeleteTable { table: "users".into() }, + "Delete table: users" + )] + #[case( + MigrationAction::AddColumn { + table: "users".into(), + column: ColumnDef { + name: "name".into(), + r#type: ColumnType::Text, + nullable: true, + default: None, + }, + fill_with: None, + }, + "Add column: users.name" + )] + #[case( + MigrationAction::RenameColumn { + table: "users".into(), + from: "old".into(), + to: "new".into(), + }, + "Rename column: users.old -> new" + )] + #[case( + MigrationAction::DeleteColumn { table: "users".into(), column: "name".into() }, + "Delete column: users.name" + )] + #[case( + MigrationAction::ModifyColumnType { + table: "users".into(), + column: "id".into(), + new_type: ColumnType::Integer, + }, + "Modify column type: users.id" + )] + #[case( + MigrationAction::AddIndex { + table: "users".into(), + index: vespertide_core::IndexDef { + name: "idx".into(), + columns: vec!["id".into()], + unique: false, + }, + }, + "Add index: idx on users" + )] + #[case( + MigrationAction::RemoveIndex { table: "users".into(), name: "idx".into() }, + "Remove index: idx from users" + )] + #[case( + MigrationAction::RenameTable { from: "users".into(), to: "accounts".into() }, + "Rename table: users -> accounts" + )] + #[serial] + fn format_action_cases(#[case] action: MigrationAction, #[case] expected: &str) { + 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 353b0fd2..d0108d88 100644 --- a/crates/vespertide-cli/src/commands/init.rs +++ b/crates/vespertide-cli/src/commands/init.rs @@ -1,6 +1,6 @@ use std::{fs, path::PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use vespertide_config::VespertideConfig; pub fn cmd_init() -> Result<()> { @@ -16,3 +16,48 @@ pub fn cmd_init() -> Result<()> { 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 4bf6e2b3..77e709c3 100644 --- a/crates/vespertide-cli/src/commands/log.rs +++ b/crates/vespertide-cli/src/commands/log.rs @@ -40,3 +40,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 fa838231..bcf753b9 100644 --- a/crates/vespertide-cli/src/commands/new.rs +++ b/crates/vespertide-cli/src/commands/new.rs @@ -71,6 +71,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 c03bd18b..8238d7f1 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -29,8 +29,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,8 +48,7 @@ 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); @@ -61,3 +59,114 @@ pub fn cmd_revision(message: String) -> Result<()> { 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 cee7a8b1..04a2cbff 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -12,13 +12,17 @@ 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."); 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); if let Some(created_at) = &plan.created_at { @@ -41,3 +45,101 @@ pub fn cmd_sql() -> Result<()> { 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, TableDef, TableConstraint}; + + 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 4d04de9a..a5477524 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -11,7 +11,10 @@ pub fn cmd_status() -> Result<()> { println!("Configuration:"); println!(" Models directory: {}", config.models_dir().display()); - println!(" Migrations directory: {}", config.migrations_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()); @@ -37,21 +40,21 @@ pub fn cmd_status() -> Result<()> { println!("Current models: {}", current_models.len()); for model in ¤t_models { - println!(" - {} ({} columns, {} indexes)", - model.name, + println!( + " - {} ({} columns, {} indexes)", + model.name, model.columns.len(), - model.indexes.len()); + model.indexes.len() + ); } 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."); @@ -68,3 +71,132 @@ pub fn cmd_status() -> Result<()> { 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 d2f112fd..56129a58 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) @@ -134,7 +131,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::>() @@ -199,4 +202,3 @@ fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) - name } } - diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index f05d9cef..0ecd9873 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -80,4 +80,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 c5f8ae8c..bb16a604 100644 --- a/crates/vespertide-config/src/file_format.rs +++ b/crates/vespertide-config/src/file_format.rs @@ -15,4 +15,3 @@ impl Default for FileFormat { FileFormat::Json } } - diff --git a/crates/vespertide-config/src/lib.rs b/crates/vespertide-config/src/lib.rs index 6809bee3..494e9820 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 8302045f..dd608c11 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 7c20b6d1..172d6a6d 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 b532fb25..b550a557 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 5bb8c8f7..e9606220 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 d371cd06..47c58899 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 17bc63a4..5dcdda3d 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 353f4e0f..fadbeacb 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 9c2e8066..5f01e865 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -320,4 +320,3 @@ mod tests { assert_err_kind(err, expected); } } - diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 29738f0d..402b97a7 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -231,6 +231,91 @@ mod tests { }, ] )] + #[case::delete_column( + vec![table( + "users", + vec![col("id", ColumnType::Integer), col("name", ColumnType::Text)], + vec![], + vec![], + )], + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + vec![MigrationAction::DeleteColumn { + table: "users".into(), + column: "name".into(), + }] + )] + #[case::modify_column_type( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + vec![table( + "users", + vec![col("id", ColumnType::Text)], + vec![], + vec![], + )], + vec![MigrationAction::ModifyColumnType { + table: "users".into(), + column: "id".into(), + new_type: ColumnType::Text, + }] + )] + #[case::remove_index( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![IndexDef { + name: "idx_users_id".into(), + columns: vec!["id".into()], + unique: false, + }], + )], + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + vec![MigrationAction::RemoveIndex { + table: "users".into(), + name: "idx_users_id".into(), + }] + )] + #[case::add_index_existing_table( + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![], + )], + vec![table( + "users", + vec![col("id", ColumnType::Integer)], + vec![], + vec![IndexDef { + name: "idx_users_id".into(), + columns: vec!["id".into()], + unique: true, + }], + )], + vec![MigrationAction::AddIndex { + table: "users".into(), + index: IndexDef { + name: "idx_users_id".into(), + columns: vec!["id".into()], + unique: true, + }, + }] + )] fn diff_schemas_detects_additions( #[case] from_schema: Vec, #[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 1caed91e..566e62cd 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 d6109638..433722b5 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 27f6c47f..985d4248 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 1acec4c1..78174af2 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 3e876c5c..90289efa 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(), )); @@ -506,13 +510,8 @@ mod tests { None => assert!(result.is_ok()), Some(pred) => { let err = result.unwrap_err(); - assert!( - pred(&err), - "unexpected error: {:?}", - err - ); + assert!(pred(&err), "unexpected error: {:?}", err); } } } } - diff --git a/crates/vespertide-query/Cargo.toml b/crates/vespertide-query/Cargo.toml index ed3c819a..f004292e 100644 --- a/crates/vespertide-query/Cargo.toml +++ b/crates/vespertide-query/Cargo.toml @@ -13,4 +13,4 @@ vespertide-core = { workspace = true } thiserror = "2" [dev-dependencies] -rstest = "0.21" \ No newline at end of file +rstest = "0.26" diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 864818f2..235afc13 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(); @@ -91,8 +91,11 @@ mod tests { 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); + 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 f414b843..889ba5cb 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 6f1f9e9b..b522cbbb 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 0b78b8e4..a0e4b039 100644 --- a/crates/vespertide-query/src/sql.rs +++ b/crates/vespertide-query/src/sql.rs @@ -578,7 +578,11 @@ mod tests { 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); + assert_eq!( + result[i].binds, *expected_binds, + "Query {} mismatch binds", + i + ); } } @@ -649,10 +653,7 @@ mod tests { vec!["name".to_string(), "'default'".to_string()], ) )] - fn test_column_def_sql( - #[case] column: ColumnDef, - #[case] expected: (String, Vec), - ) { + 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); diff --git a/crates/vespertide-schema-gen/src/main.rs b/crates/vespertide-schema-gen/src/main.rs index 2e0e7755..fffb60b1 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")] @@ -58,7 +61,7 @@ mod tests { 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()); @@ -68,12 +71,12 @@ mod tests { 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")); @@ -83,12 +86,12 @@ mod tests { 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")); @@ -98,19 +101,19 @@ mod tests { 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(); } @@ -119,18 +122,17 @@ mod tests { 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()); } } - From 62e0ca525c3499d04f4f30546cd8ab56b3223e17 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 21:45:28 +0900 Subject: [PATCH 06/12] Add testcase --- crates/vespertide-cli/src/commands/revision.rs | 11 ++++++++--- crates/vespertide-cli/src/commands/sql.rs | 4 +++- crates/vespertide-cli/src/commands/status.rs | 5 +---- crates/vespertide-cli/src/utils.rs | 11 ----------- crates/vespertide-config/src/config.rs | 4 ---- crates/vespertide-config/src/file_format.rs | 10 ++++++++++ 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index 8238d7f1..b33a9011 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -164,9 +164,14 @@ mod tests { 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)); + 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 04a2cbff..01884606 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -53,7 +53,9 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; use vespertide_config::VespertideConfig; - use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan, TableDef, TableConstraint}; + use vespertide_core::{ + ColumnDef, ColumnType, MigrationAction, MigrationPlan, TableConstraint, TableDef, + }; struct CwdGuard { original: PathBuf, diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index a5477524..83dcd27d 100644 --- a/crates/vespertide-cli/src/commands/status.rs +++ b/crates/vespertide-cli/src/commands/status.rs @@ -76,10 +76,7 @@ pub fn cmd_status() -> Result<()> { mod tests { use super::*; use serial_test::serial; - use std::{ - fs, - path::PathBuf, - }; + use std::{fs, path::PathBuf}; use tempfile::tempdir; use vespertide_config::VespertideConfig; use vespertide_core::{ColumnDef, ColumnType, MigrationAction, MigrationPlan, TableDef}; diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index 56129a58..ebe92742 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -96,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, diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index 0ecd9873..193cc26d 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 diff --git a/crates/vespertide-config/src/file_format.rs b/crates/vespertide-config/src/file_format.rs index bb16a604..63264962 100644 --- a/crates/vespertide-config/src/file_format.rs +++ b/crates/vespertide-config/src/file_format.rs @@ -15,3 +15,13 @@ impl Default for FileFormat { FileFormat::Json } } + +#[cfg(test)] +mod tests { + use super::FileFormat; + + #[test] + fn default_is_json() { + assert_eq!(FileFormat::default(), FileFormat::Json); + } +} From ce47312556180cc3509fe6ee2e0a68b61f9e9bbf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 22:16:11 +0900 Subject: [PATCH 07/12] Add testcase --- crates/vespertide-cli/src/utils.rs | 135 +++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index ebe92742..64bf3ede 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -191,3 +191,138 @@ fn render_migration_name(pattern: &str, version: u32, sanitized_comment: &str) - name } } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + use vespertide_core::{ColumnDef, ColumnType, TableConstraint}; + + 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"); + } +} From 3baa76c67b140ae5dbf0710fe6ce7c29d462c31a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 10 Dec 2025 22:21:25 +0900 Subject: [PATCH 08/12] Fix code style --- crates/vespertide-planner/src/apply.rs | 48 ++++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index 5f01e865..fa1f5724 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -28,9 +28,11 @@ 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 +44,15 @@ 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 +77,13 @@ 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 +118,24 @@ 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(()) } } } From 39338785c6b9d61b93ae9a5f136c3c4aec04917b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 00:17:21 +0900 Subject: [PATCH 09/12] Add testcase --- crates/vespertide-planner/src/apply.rs | 294 +++++++++++++++++++++++++ 1 file changed, 294 insertions(+) diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index fa1f5724..f9e0dfaa 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -321,6 +321,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, @@ -329,4 +340,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); + } } From 8b74a2d4af26ab9f12d4c4af91de80152ddafe1b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 00:18:30 +0900 Subject: [PATCH 10/12] Add testcase --- .changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json diff --git a/.changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json b/.changepacks/changepack_log_S1DsH130KtuAB7xqfxqub.json new file mode 100644 index 00000000..8ee5bacc --- /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 From 865f880c18f61bc4ac8cebcd8159fdaddc8c4832 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 11 Dec 2025 00:48:35 +0900 Subject: [PATCH 11/12] Add color --- Cargo.lock | 93 +++++++++++++- crates/vespertide-cli/Cargo.toml | 1 + crates/vespertide-cli/src/commands/diff.rs | 107 ++++++++++++---- crates/vespertide-cli/src/commands/init.rs | 7 +- crates/vespertide-cli/src/commands/log.rs | 44 +++++-- crates/vespertide-cli/src/commands/new.rs | 7 +- .../vespertide-cli/src/commands/revision.rs | 29 ++++- crates/vespertide-cli/src/commands/sql.rs | 43 +++++-- crates/vespertide-cli/src/commands/status.rs | 114 ++++++++++++++---- crates/vespertide-config/src/file_format.rs | 8 +- crates/vespertide-planner/src/apply.rs | 15 +-- crates/vespertide-planner/src/diff.rs | 16 +-- 12 files changed, 382 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 233a366d..7c3f9872 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]] @@ -172,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" @@ -197,7 +206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -627,7 +636,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -831,7 +840,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -916,6 +925,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "colored", "rstest", "schemars", "serde_json", @@ -1095,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" @@ -1104,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" diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 9a35a445..95e65b64 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" diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index e8d9de80..a2afbe70 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,13 +14,26 @@ 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."); + println!( + "{} {}", + "No differences found.".bright_green(), + "Schema is up to date.".bright_white() + ); } else { - println!("Found {} change(s) to apply:", plan.actions.len()); + 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)); + println!( + "{}. {}", + (i + 1).to_string().bright_magenta().bold(), + format_action(action) + ); } } Ok(()) @@ -28,31 +42,79 @@ pub fn cmd_diff() -> Result<()> { 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() + ) } } } @@ -60,6 +122,7 @@ fn format_action(action: &MigrationAction) -> String { #[cfg(test)] mod tests { use super::*; + use colored::Colorize; use rstest::rstest; use serial_test::serial; use std::fs; @@ -113,11 +176,11 @@ mod tests { #[rstest] #[case( MigrationAction::CreateTable { table: "users".into(), columns: vec![], constraints: vec![] }, - "Create table: users" + format!("{} {}", "Create table:".bright_green(), "users".bright_cyan().bold()) )] #[case( MigrationAction::DeleteTable { table: "users".into() }, - "Delete table: users" + format!("{} {}", "Delete table:".bright_red(), "users".bright_cyan().bold()) )] #[case( MigrationAction::AddColumn { @@ -130,7 +193,7 @@ mod tests { }, fill_with: None, }, - "Add column: users.name" + format!("{} {}.{}", "Add column:".bright_green(), "users".bright_cyan(), "name".bright_cyan().bold()) )] #[case( MigrationAction::RenameColumn { @@ -138,11 +201,11 @@ mod tests { from: "old".into(), to: "new".into(), }, - "Rename column: users.old -> new" + 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() }, - "Delete column: users.name" + format!("{} {}.{}", "Delete column:".bright_red(), "users".bright_cyan(), "name".bright_cyan().bold()) )] #[case( MigrationAction::ModifyColumnType { @@ -150,7 +213,7 @@ mod tests { column: "id".into(), new_type: ColumnType::Integer, }, - "Modify column type: users.id" + format!("{} {}.{}", "Modify column type:".bright_yellow(), "users".bright_cyan(), "id".bright_cyan().bold()) )] #[case( MigrationAction::AddIndex { @@ -161,18 +224,18 @@ mod tests { unique: false, }, }, - "Add index: idx on users" + format!("{} {} {} {}", "Add index:".bright_green(), "idx".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan()) )] #[case( MigrationAction::RemoveIndex { table: "users".into(), name: "idx".into() }, - "Remove index: idx from users" + format!("{} {} {} {}", "Remove index:".bright_red(), "idx".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan()) )] #[case( MigrationAction::RenameTable { from: "users".into(), to: "accounts".into() }, - "Rename table: users -> accounts" + format!("{} {} {} {}", "Rename table:".bright_yellow(), "users".bright_cyan(), "->".bright_white(), "accounts".bright_cyan().bold()) )] #[serial] - fn format_action_cases(#[case] action: MigrationAction, #[case] expected: &str) { + fn format_action_cases(#[case] action: MigrationAction, #[case] expected: String) { assert_eq!(format_action(&action), expected); } diff --git a/crates/vespertide-cli/src/commands/init.rs b/crates/vespertide-cli/src/commands/init.rs index d0108d88..a8422da3 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::{Context, Result, bail}; +use colored::Colorize; use vespertide_config::VespertideConfig; pub fn cmd_init() -> Result<()> { @@ -12,7 +13,11 @@ 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(()) } diff --git a/crates/vespertide-cli/src/commands/log.rs b/crates/vespertide-cli/src/commands/log.rs index 77e709c3..0e1b1eaf 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!(); diff --git a/crates/vespertide-cli/src/commands/new.rs b/crates/vespertide-cli/src/commands/new.rs index bcf753b9..55e12aa8 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(()) } diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index b33a9011..2d81355f 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,7 +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(); @@ -50,11 +55,23 @@ pub fn cmd_revision(message: String) -> Result<()> { 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(()) diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index 01884606..a07338d5 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; @@ -17,28 +18,52 @@ pub fn cmd_sql() -> Result<()> { 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))?; + 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); } } diff --git a/crates/vespertide-cli/src/commands/status.rs b/crates/vespertide-cli/src/commands/status.rs index 83dcd27d..e35523a6 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,42 +10,79 @@ 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!("{}", "Configuration:".bright_cyan().bold()); println!( - " Migrations directory: {}", - config.migrations_dir().display() + " {} {}", + "Models directory:".cyan(), + format!("{}", config.models_dir().display()).bright_white() ); - 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!( - " Migration filename pattern: {}", - config.migration_filename_pattern() + " {} {}", + "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() + " {} {} ({} {}, {} {})", + "-".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!(); @@ -57,16 +95,42 @@ pub fn cmd_status() -> Result<()> { 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(()) diff --git a/crates/vespertide-config/src/file_format.rs b/crates/vespertide-config/src/file_format.rs index 63264962..7328b15b 100644 --- a/crates/vespertide-config/src/file_format.rs +++ b/crates/vespertide-config/src/file_format.rs @@ -4,18 +4,14 @@ 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; diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index f9e0dfaa..a1ce1e2a 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -29,8 +29,7 @@ pub fn apply_action( schema.retain(|t| t.name != *table); if schema.len() == before { Err(PlannerError::TableNotFound(table.clone())) - } - else { + } else { Ok(()) } } @@ -48,8 +47,7 @@ pub fn apply_action( table.clone(), column.name.clone(), )) - } - else { + } else { tbl.columns.push(column.clone()); Ok(()) } @@ -78,8 +76,7 @@ pub fn apply_action( tbl.columns.retain(|c| c.name != *column); if tbl.columns.len() == before { Err(PlannerError::ColumnNotFound(table.clone(), column.clone())) - } - else { + } else { drop_column_from_constraints(&mut tbl.constraints, column); drop_column_from_indexes(&mut tbl.indexes, column); Ok(()) @@ -119,16 +116,14 @@ pub fn apply_action( tbl.indexes.retain(|i| i.name != *name); if tbl.indexes.len() == before { Err(PlannerError::IndexNotFound(table.clone(), name.clone())) - } - else { + } else { Ok(()) } } MigrationAction::RenameTable { from, to } => { if schema.iter().any(|t| t.name == *to) { Err(PlannerError::TableExists(to.clone())) - } - else { + } else { let tbl = schema .iter_mut() .find(|t| t.name == *from) diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 402b97a7..036213bc 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 Date: Thu, 11 Dec 2025 00:57:06 +0900 Subject: [PATCH 12/12] Fix lint --- crates/vespertide-cli/src/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index 64bf3ede..002f0b09 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -198,7 +198,7 @@ mod tests { use serial_test::serial; use std::fs; use tempfile::tempdir; - use vespertide_core::{ColumnDef, ColumnType, TableConstraint}; + use vespertide_core::{ColumnDef, ColumnType}; struct CwdGuard { original: PathBuf,