diff --git a/.changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json b/.changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json new file mode 100644 index 0000000..150e0bc --- /dev/null +++ b/.changepacks/changepack_log_PFe8JgOcrIeEzleBAGPSC.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch"},"note":"Add auto increment","date":"2025-12-14T15:20:16.940927400Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json b/.changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json new file mode 100644 index 0000000..ea607e4 --- /dev/null +++ b/.changepacks/changepack_log_PtMoTb6mIuZ84njX_hTSY.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch"},"note":"Add fk validation","date":"2025-12-14T15:20:26.765917400Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json b/.changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json new file mode 100644 index 0000000..9b52c36 --- /dev/null +++ b/.changepacks/changepack_log_h3wS_OvJO-t6o_K_bYFid.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch"},"note":"Fix exporting logic","date":"2025-12-14T14:48:31.944130300Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 83ee82d..d3c731e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -242,6 +257,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -540,6 +566,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -640,6 +672,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.11.1" @@ -1228,6 +1269,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-bigint" version = "0.4.6" @@ -1505,6 +1552,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1900,9 +1977,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.20" +version = "2.0.0-rc.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "880cc0a64f1ee0320d7d6cd2b9290c27e89750669cfc5b6afdce1654755a2983" +checksum = "83b0bd6374d233e1553becb8786e22665d958641a1d72d5fdb52b9c07d2ce8d8" dependencies = [ "async-stream", "async-trait", @@ -1932,9 +2009,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.20" +version = "2.0.0-rc.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aae91cb86cf389da96119df71967ffb1b3caf1687303def77336f8dc9d33e78" +checksum = "c295fd7665874275dd6926efe45efdecd0e54b98b5c177f95970fe4af6b213c9" dependencies = [ "heck 0.5.0", "pluralizer", @@ -2560,6 +2637,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.17" @@ -2831,7 +2914,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.5" +version = "0.1.6" dependencies = [ "vespertide-core", "vespertide-macro", @@ -2839,12 +2922,14 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", + "assert_cmd", "chrono", "clap", "colored", + "predicates", "rstest", "schemars", "serde_json", @@ -2860,7 +2945,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.5" +version = "0.1.6" dependencies = [ "clap", "serde", @@ -2868,8 +2953,9 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.5" +version = "0.1.6" dependencies = [ + "rstest", "schemars", "serde", "thiserror", @@ -2877,7 +2963,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.5" +version = "0.1.6" dependencies = [ "insta", "rstest", @@ -2887,13 +2973,14 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.5" +version = "0.1.6" dependencies = [ "proc-macro2", "quote", "serde_json", "serde_yaml", "syn 2.0.111", + "tempfile", "thiserror", "vespertide-config", "vespertide-core", @@ -2902,7 +2989,7 @@ dependencies = [ [[package]] name = "vespertide-planner" -version = "0.1.5" +version = "0.1.6" dependencies = [ "rstest", "thiserror", @@ -2911,7 +2998,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.5" +version = "0.1.6" dependencies = [ "rstest", "thiserror", @@ -2923,13 +3010,24 @@ name = "vespertide-schema-gen" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", "clap", + "predicates", "schemars", "serde_json", "tempfile", "vespertide-core", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 9b65c30..b5c7a25 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -27,6 +27,8 @@ vespertide-exporter = { workspace = true } tempfile = "3" serial_test = "3" rstest = "0.26" +assert_cmd = "2" +predicates = "3" [[bin]] name = "vespertide" diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index c31f33f..324e932 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -146,7 +146,7 @@ fn format_action(action: &MigrationAction) -> String { fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> String { match constraint { - vespertide_core::TableConstraint::PrimaryKey { columns } => { + vespertide_core::TableConstraint::PrimaryKey { columns, .. } => { format!("PRIMARY KEY ({})", columns.join(", ")) } vespertide_core::TableConstraint::Unique { name, columns } => { @@ -311,6 +311,7 @@ mod tests { MigrationAction::AddConstraint { table: "users".into(), constraint: vespertide_core::TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }, @@ -354,6 +355,7 @@ mod tests { MigrationAction::RemoveConstraint { table: "users".into(), constraint: vespertide_core::TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }, }, diff --git a/crates/vespertide-cli/src/commands/export.rs b/crates/vespertide-cli/src/commands/export.rs index 5e058f8..c3c8d28 100644 --- a/crates/vespertide-cli/src/commands/export.rs +++ b/crates/vespertide-cli/src/commands/export.rs @@ -67,16 +67,50 @@ fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> } fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf { - let mut out = root.join(rel_path); - // swap extension based on ORM - let ext = match orm { - Orm::SeaOrm => "rs", - Orm::SqlAlchemy | Orm::SqlModel => "py", - }; - out.set_extension(ext); + // Sanitize file name: replace spaces with underscores + let mut out = root.to_path_buf(); + + // Reconstruct path with sanitized file name + for component in rel_path.components() { + if let std::path::Component::Normal(name) = component { + out.push(name); + } else { + out.push(component.as_os_str()); + } + } + + // Sanitize the file name (last component) + if let Some(file_name) = out.file_name().and_then(|n| n.to_str()) { + // Remove extension, sanitize, then add new extension + let (stem, _ext) = if let Some(dot_idx) = file_name.rfind('.') { + file_name.split_at(dot_idx) + } else { + (file_name, "") + }; + + let sanitized = sanitize_filename(stem); + let ext = match orm { + Orm::SeaOrm => "rs", + Orm::SqlAlchemy | Orm::SqlModel => "py", + }; + out.set_file_name(format!("{}.{}", sanitized, ext)); + } + out } +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|ch| { + if ch.is_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect::() +} + fn load_models_recursive(base: &Path) -> Result> { let mut out = Vec::new(); if !base.exists() { @@ -91,7 +125,11 @@ fn ensure_mod_chain(root: &Path, rel_path: &Path) -> Result<()> { let mut comps: Vec = rel_path .with_extension("") .components() - .filter_map(|c| c.as_os_str().to_str().map(|s| s.to_string())) + .filter_map(|c| { + c.as_os_str() + .to_str() + .map(|s| sanitize_filename(s).to_string()) + }) .collect(); if comps.is_empty() { return Ok(()); @@ -153,6 +191,7 @@ fn walk_models(root: &Path, current: &Path, acc: &mut Vec<(TableDef, PathBuf)>) #[cfg(test)] mod tests { use super::*; + use rstest::rstest; use serial_test::serial; use std::fs; use tempfile::tempdir; @@ -204,6 +243,7 @@ mod tests { foreign_key: None, }], constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], indexes: vec![], @@ -381,10 +421,64 @@ mod tests { assert_eq!(resolved, override_dir); } + #[rstest] + #[case(OrmArg::Seaorm, Orm::SeaOrm)] + #[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)] + #[case(OrmArg::Sqlmodel, Orm::SqlModel)] + fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) { + assert_eq!(Orm::from(arg), expected); + } + + #[rstest] + #[case("normal_name", "normal_name")] + #[case("user copy", "user_copy")] + #[case("user copy", "user__copy")] + #[case("user-copy", "user-copy")] + #[case("user.copy", "user_copy")] + #[case("user copy.json", "user_copy_json")] + fn test_sanitize_filename(#[case] input: &str, #[case] expected: &str) { + assert_eq!(sanitize_filename(input), expected); + } + + #[test] + fn build_output_path_sanitizes_spaces() { + use std::path::Path; + let root = Path::new("src/models"); + let rel_path = Path::new("user copy.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/user_copy.rs")); + + let rel_path2 = Path::new("blog/post name.yaml"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert_eq!(out2, Path::new("src/models/blog/post_name.rs")); + } + + #[test] + fn build_output_path_handles_file_without_extension() { + use std::path::Path; + let root = Path::new("src/models"); + // File without extension - covers line 88 (else branch) + let rel_path = Path::new("users"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + assert_eq!(out, Path::new("src/models/users.rs")); + + let out_py = build_output_path(root, rel_path, Orm::SqlAlchemy); + assert_eq!(out_py, Path::new("src/models/users.py")); + } + #[test] - fn orm_arg_maps_to_enum() { - assert!(matches!(Orm::from(OrmArg::Seaorm), Orm::SeaOrm)); - assert!(matches!(Orm::from(OrmArg::Sqlalchemy), Orm::SqlAlchemy)); - assert!(matches!(Orm::from(OrmArg::Sqlmodel), Orm::SqlModel)); + fn build_output_path_handles_special_path_components() { + use std::path::Path; + let root = Path::new("src/models"); + // Path with CurDir component (.) - covers line 78 (non-Normal component branch) + let rel_path = Path::new("./blog/posts.json"); + let out = build_output_path(root, rel_path, Orm::SeaOrm); + // The . component gets pushed via the else branch + assert!(out.to_string_lossy().contains("posts")); + + // Path with ParentDir component (..) + let rel_path2 = Path::new("../other/items.yaml"); + let out2 = build_output_path(root, rel_path2, Orm::SeaOrm); + assert!(out2.to_string_lossy().contains("items")); } } diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index b660d81..cc4b22a 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -176,6 +176,7 @@ mod tests { foreign_key: None, }], constraints: vec![TableConstraint::PrimaryKey { + auto_increment: false, columns: vec!["id".into()], }], }], diff --git a/crates/vespertide-cli/src/utils.rs b/crates/vespertide-cli/src/utils.rs index f27db58..c4549bb 100644 --- a/crates/vespertide-cli/src/utils.rs +++ b/crates/vespertide-cli/src/utils.rs @@ -29,12 +29,23 @@ pub fn load_models(config: &VespertideConfig) -> Result> { let mut tables = Vec::new(); load_models_recursive(models_dir, &mut tables)?; + // Normalize tables to convert inline constraints (primary_key, foreign_key, etc.) to table-level constraints + // This must happen before validation so that foreign key references can be checked + let normalized_tables: Vec = tables + .into_iter() + .map(|t| { + t.normalize() + .map_err(|e| anyhow::anyhow!("Failed to normalize table '{}': {}", t.name, e)) + }) + .collect::, _>>()?; + // Validate schema integrity before returning - if !tables.is_empty() { - validate_schema(&tables).map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; + if !normalized_tables.is_empty() { + validate_schema(&normalized_tables) + .map_err(|e| anyhow::anyhow!("schema validation failed: {}", e))?; } - Ok(tables) + Ok(normalized_tables) } /// Recursively walk directory and load model files. @@ -218,7 +229,9 @@ mod tests { use serial_test::serial; use std::fs; use tempfile::tempdir; - use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + use vespertide_core::{ + ColumnDef, ColumnType, SimpleColumnType, schema::foreign_key::ForeignKeySyntax, + }; struct CwdGuard { original: PathBuf, @@ -376,4 +389,43 @@ mod tests { let name = migration_filename_with_format_and_pattern(version, comment, format, pattern); assert_eq!(name, expected); } + + #[test] + #[serial] + fn load_models_fails_on_invalid_fk_format() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models").unwrap(); + + // Create a model with invalid FK string format (missing dot separator) + let table = TableDef { + name: "orders".into(), + columns: vec![ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + // Invalid FK format: should be "table.column" but missing the dot + foreign_key: Some(ForeignKeySyntax::String("invalid_format".into())), + }], + constraints: vec![], + indexes: vec![], + }; + fs::write( + "models/orders.json", + serde_json::to_string_pretty(&table).unwrap(), + ) + .unwrap(); + + let result = load_models(&VespertideConfig::default()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("Failed to normalize table 'orders'")); + } } diff --git a/crates/vespertide-cli/tests/integration_test.rs b/crates/vespertide-cli/tests/integration_test.rs new file mode 100644 index 0000000..3e50cd3 --- /dev/null +++ b/crates/vespertide-cli/tests/integration_test.rs @@ -0,0 +1,82 @@ +use assert_cmd::Command; +use assert_cmd::cargo; +use predicates::prelude::*; + +fn vespertide() -> Command { + Command::new(cargo::cargo_bin!("vespertide")) +} + +#[test] +fn test_main_with_no_args_shows_help() { + vespertide() + .assert() + .success() + .stdout(predicate::str::contains("vespertide")); +} + +#[test] +fn test_main_with_help_flag() { + vespertide() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("vespertide")); +} + +#[test] +fn test_main_with_diff_command() { + // This will fail if not in a vespertide project, but tests the code path + let mut cmd = vespertide(); + cmd.arg("diff"); + // Don't assert success since it may fail outside a project + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_sql_command() { + let mut cmd = vespertide(); + cmd.arg("sql"); + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_log_command() { + let mut cmd = vespertide(); + cmd.arg("log"); + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_status_command() { + let mut cmd = vespertide(); + cmd.arg("status"); + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_init_command() { + let mut cmd = vespertide(); + cmd.arg("init"); + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_new_command() { + let mut cmd = vespertide(); + cmd.args(["new", "test_table"]); + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_revision_command() { + let mut cmd = vespertide(); + cmd.args(["revision", "-m", "test message"]); + let _ = cmd.assert(); +} + +#[test] +fn test_main_with_export_command() { + let mut cmd = vespertide(); + cmd.args(["export", "--orm", "seaorm"]); + let _ = cmd.assert(); +} diff --git a/crates/vespertide-core/Cargo.toml b/crates/vespertide-core/Cargo.toml index af54405..cc44cc6 100644 --- a/crates/vespertide-core/Cargo.toml +++ b/crates/vespertide-core/Cargo.toml @@ -12,3 +12,6 @@ description = "Data models for tables, columns, constraints, indexes, and migrat serde = { version = "1", features = ["derive"] } schemars = { version = "1.1" } thiserror = "2" + +[dev-dependencies] +rstest = "0.26" diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index 934523f..3b52953 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -15,56 +15,57 @@ pub struct MigrationPlan { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type")] +#[serde(tag = "type", rename_all = "snake_case")] pub enum MigrationAction { - #[serde(rename_all = "snake_case")] CreateTable { table: TableName, columns: Vec, constraints: Vec, }, - #[serde(rename_all = "snake_case")] - DeleteTable { table: TableName }, - #[serde(rename_all = "snake_case")] + DeleteTable { + table: TableName, + }, AddColumn { table: TableName, column: ColumnDef, /// Optional fill value to backfill existing rows when adding NOT NULL without default. fill_with: Option, }, - #[serde(rename_all = "snake_case")] RenameColumn { table: TableName, from: ColumnName, to: ColumnName, }, - #[serde(rename_all = "snake_case")] DeleteColumn { table: TableName, column: ColumnName, }, - #[serde(rename_all = "snake_case")] ModifyColumnType { table: TableName, column: ColumnName, new_type: ColumnType, }, - #[serde(rename_all = "snake_case")] - AddIndex { table: TableName, index: IndexDef }, - #[serde(rename_all = "snake_case")] - RemoveIndex { table: TableName, name: IndexName }, - #[serde(rename_all = "snake_case")] + AddIndex { + table: TableName, + index: IndexDef, + }, + RemoveIndex { + table: TableName, + name: IndexName, + }, AddConstraint { table: TableName, constraint: TableConstraint, }, - #[serde(rename_all = "snake_case")] RemoveConstraint { table: TableName, constraint: TableConstraint, }, - #[serde(rename_all = "snake_case")] - RenameTable { from: TableName, to: TableName }, - #[serde(rename_all = "snake_case")] - RawSql { sql: String }, + RenameTable { + from: TableName, + to: TableName, + }, + RawSql { + sql: String, + }, } diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index d5d4919..000e05c 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -1,7 +1,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::schema::{foreign_key::ForeignKeyDef, names::ColumnName, str_or_bool::StrOrBoolOrArray}; +use crate::schema::{ + foreign_key::ForeignKeySyntax, names::ColumnName, primary_key::PrimaryKeySyntax, + str_or_bool::StrOrBoolOrArray, +}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -11,10 +14,10 @@ pub struct ColumnDef { pub nullable: bool, pub default: Option, pub comment: Option, - pub primary_key: Option, + pub primary_key: Option, pub unique: Option, pub index: Option, - pub foreign_key: Option, + pub foreign_key: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -40,6 +43,7 @@ impl ColumnType { SimpleColumnType::Time => "TIME".into(), SimpleColumnType::Timestamp => "TIMESTAMP".into(), SimpleColumnType::Timestamptz => "TIMESTAMPTZ".into(), + SimpleColumnType::Interval => "INTERVAL".into(), SimpleColumnType::Bytea => "BYTEA".into(), SimpleColumnType::Uuid => "UUID".into(), SimpleColumnType::Json => "JSON".into(), @@ -47,9 +51,14 @@ impl ColumnType { SimpleColumnType::Inet => "INET".into(), SimpleColumnType::Cidr => "CIDR".into(), SimpleColumnType::Macaddr => "MACADDR".into(), + SimpleColumnType::Xml => "XML".into(), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Varchar { length } => format!("VARCHAR({})", length), + ComplexColumnType::Numeric { precision, scale } => { + format!("NUMERIC({}, {})", precision, scale) + } + ComplexColumnType::Char { length } => format!("CHAR({})", length), ComplexColumnType::Custom { custom_type } => custom_type.clone(), }, } @@ -70,14 +79,18 @@ impl ColumnType { SimpleColumnType::Time => "Time".to_string(), SimpleColumnType::Timestamp => "DateTime".to_string(), SimpleColumnType::Timestamptz => "DateTimeWithTimeZone".to_string(), + SimpleColumnType::Interval => "String".to_string(), SimpleColumnType::Bytea => "Vec".to_string(), SimpleColumnType::Uuid => "Uuid".to_string(), SimpleColumnType::Json | SimpleColumnType::Jsonb => "Json".to_string(), SimpleColumnType::Inet | SimpleColumnType::Cidr => "String".to_string(), SimpleColumnType::Macaddr => "String".to_string(), + SimpleColumnType::Xml => "String".to_string(), }, ColumnType::Complex(ty) => match ty { ComplexColumnType::Varchar { .. } => "String".to_string(), + ComplexColumnType::Numeric { .. } => "Decimal".to_string(), + ComplexColumnType::Char { .. } => "String".to_string(), ComplexColumnType::Custom { .. } => "String".to_string(), // Default for custom types }, }; @@ -110,6 +123,7 @@ pub enum SimpleColumnType { Time, Timestamp, Timestamptz, + Interval, // Binary type Bytea, @@ -125,23 +139,168 @@ pub enum SimpleColumnType { Inet, Cidr, Macaddr, + + // XML type + Xml, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum ComplexColumnType { Varchar { length: u32 }, + Numeric { precision: u32, scale: u32 }, + Char { length: u32 }, Custom { custom_type: String }, } -impl From for ColumnType { - fn from(ty: SimpleColumnType) -> Self { - ColumnType::Simple(ty) +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(SimpleColumnType::SmallInt, "SMALLINT")] + #[case(SimpleColumnType::Integer, "INTEGER")] + #[case(SimpleColumnType::BigInt, "BIGINT")] + #[case(SimpleColumnType::Real, "REAL")] + #[case(SimpleColumnType::DoublePrecision, "DOUBLE PRECISION")] + #[case(SimpleColumnType::Text, "TEXT")] + #[case(SimpleColumnType::Boolean, "BOOLEAN")] + #[case(SimpleColumnType::Date, "DATE")] + #[case(SimpleColumnType::Time, "TIME")] + #[case(SimpleColumnType::Timestamp, "TIMESTAMP")] + #[case(SimpleColumnType::Timestamptz, "TIMESTAMPTZ")] + #[case(SimpleColumnType::Interval, "INTERVAL")] + #[case(SimpleColumnType::Bytea, "BYTEA")] + #[case(SimpleColumnType::Uuid, "UUID")] + #[case(SimpleColumnType::Json, "JSON")] + #[case(SimpleColumnType::Jsonb, "JSONB")] + #[case(SimpleColumnType::Inet, "INET")] + #[case(SimpleColumnType::Cidr, "CIDR")] + #[case(SimpleColumnType::Macaddr, "MACADDR")] + #[case(SimpleColumnType::Xml, "XML")] + fn test_simple_column_type_to_sql( + #[case] column_type: SimpleColumnType, + #[case] expected: &str, + ) { + assert_eq!(ColumnType::Simple(column_type).to_sql(), expected); + } + + #[rstest] + #[case(ComplexColumnType::Varchar { length: 255 }, "VARCHAR(255)")] + #[case(ComplexColumnType::Varchar { length: 50 }, "VARCHAR(50)")] + #[case(ComplexColumnType::Varchar { length: 1 }, "VARCHAR(1)")] + #[case(ComplexColumnType::Numeric { precision: 10, scale: 2 }, "NUMERIC(10, 2)")] + #[case(ComplexColumnType::Numeric { precision: 5, scale: 0 }, "NUMERIC(5, 0)")] + #[case(ComplexColumnType::Numeric { precision: 18, scale: 4 }, "NUMERIC(18, 4)")] + #[case(ComplexColumnType::Char { length: 10 }, "CHAR(10)")] + #[case(ComplexColumnType::Char { length: 1 }, "CHAR(1)")] + #[case(ComplexColumnType::Char { length: 255 }, "CHAR(255)")] + #[case(ComplexColumnType::Custom { custom_type: "MONEY".into() }, "MONEY")] + #[case(ComplexColumnType::Custom { custom_type: "JSONB".into() }, "JSONB")] + #[case(ComplexColumnType::Custom { custom_type: "CUSTOM_TYPE".into() }, "CUSTOM_TYPE")] + fn test_complex_column_type_to_sql( + #[case] column_type: ComplexColumnType, + #[case] expected: &str, + ) { + assert_eq!(ColumnType::Complex(column_type).to_sql(), expected); + } + + #[rstest] + #[case(SimpleColumnType::SmallInt, "i16")] + #[case(SimpleColumnType::Integer, "i32")] + #[case(SimpleColumnType::BigInt, "i64")] + #[case(SimpleColumnType::Real, "f32")] + #[case(SimpleColumnType::DoublePrecision, "f64")] + #[case(SimpleColumnType::Text, "String")] + #[case(SimpleColumnType::Boolean, "bool")] + #[case(SimpleColumnType::Date, "Date")] + #[case(SimpleColumnType::Time, "Time")] + #[case(SimpleColumnType::Timestamp, "DateTime")] + #[case(SimpleColumnType::Timestamptz, "DateTimeWithTimeZone")] + #[case(SimpleColumnType::Interval, "String")] + #[case(SimpleColumnType::Bytea, "Vec")] + #[case(SimpleColumnType::Uuid, "Uuid")] + #[case(SimpleColumnType::Json, "Json")] + #[case(SimpleColumnType::Jsonb, "Json")] + #[case(SimpleColumnType::Inet, "String")] + #[case(SimpleColumnType::Cidr, "String")] + #[case(SimpleColumnType::Macaddr, "String")] + #[case(SimpleColumnType::Xml, "String")] + fn test_simple_column_type_to_rust_type_not_nullable( + #[case] column_type: SimpleColumnType, + #[case] expected: &str, + ) { + assert_eq!( + ColumnType::Simple(column_type).to_rust_type(false), + expected + ); + } + + #[rstest] + #[case(SimpleColumnType::SmallInt, "Option")] + #[case(SimpleColumnType::Integer, "Option")] + #[case(SimpleColumnType::BigInt, "Option")] + #[case(SimpleColumnType::Real, "Option")] + #[case(SimpleColumnType::DoublePrecision, "Option")] + #[case(SimpleColumnType::Text, "Option")] + #[case(SimpleColumnType::Boolean, "Option")] + #[case(SimpleColumnType::Date, "Option")] + #[case(SimpleColumnType::Time, "Option