From 7fbc6d46e17d37f7a9c2afb9299cdcc89a667de3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Jan 2026 16:14:56 +0900 Subject: [PATCH 1/4] Add fill with default --- .../changepack_log_MnVcYrZ0fpNFCD3yqA7rt.json | 1 + Cargo.lock | 20 +- .../vespertide-cli/src/commands/revision.rs | 68 ++++- crates/vespertide-core/src/schema/column.rs | 254 ++++++++++++++++++ crates/vespertide-planner/src/validate.rs | 6 +- 5 files changed, 325 insertions(+), 24 deletions(-) create mode 100644 .changepacks/changepack_log_MnVcYrZ0fpNFCD3yqA7rt.json diff --git a/.changepacks/changepack_log_MnVcYrZ0fpNFCD3yqA7rt.json b/.changepacks/changepack_log_MnVcYrZ0fpNFCD3yqA7rt.json new file mode 100644 index 0000000..8c8b3cc --- /dev/null +++ b/.changepacks/changepack_log_MnVcYrZ0fpNFCD3yqA7rt.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-query/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch"},"note":"Add fill_with default","date":"2026-01-21T07:14:49.745170500Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 6c525c7..cfffdca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2995,7 +2995,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.30" +version = "0.1.32" dependencies = [ "vespertide-core", "vespertide-macro", @@ -3003,7 +3003,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.30" +version = "0.1.32" dependencies = [ "anyhow", "assert_cmd", @@ -3028,7 +3028,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.30" +version = "0.1.32" dependencies = [ "clap", "schemars", @@ -3038,7 +3038,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.30" +version = "0.1.32" dependencies = [ "rstest", "schemars", @@ -3050,7 +3050,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.30" +version = "0.1.32" dependencies = [ "insta", "rstest", @@ -3061,7 +3061,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.30" +version = "0.1.32" dependencies = [ "anyhow", "rstest", @@ -3076,7 +3076,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.30" +version = "0.1.32" dependencies = [ "proc-macro2", "quote", @@ -3093,11 +3093,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.30" +version = "0.1.32" [[package]] name = "vespertide-planner" -version = "0.1.30" +version = "0.1.32" dependencies = [ "insta", "rstest", @@ -3108,7 +3108,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.30" +version = "0.1.32" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index 10ceccf..4440f4b 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -29,8 +29,14 @@ fn parse_fill_with_args(args: &[String]) -> HashMap<(String, String), String> { } /// Format the type info string for display. -fn format_type_info(column_type: Option<&String>) -> String { - column_type.map(|t| format!(" ({})", t)).unwrap_or_default() +/// Includes column type and default value hint if available. +fn format_type_info(column_type: Option<&String>, default_value: Option<&String>) -> String { + match (column_type, default_value) { + (Some(t), Some(d)) => format!(" ({}, default: {})", t, d), + (Some(t), None) => format!(" ({})", t), + (None, Some(d)) => format!(" (default: {})", d), + (None, None) => String::new(), + } } /// Format a single fill_with item for display. @@ -75,9 +81,10 @@ fn print_fill_with_item_and_get_prompt( table: &str, column: &str, column_type: Option<&String>, + default_value: Option<&String>, action_type: &str, ) -> String { - let type_info = format_type_info(column_type); + let type_info = format_type_info(column_type, default_value); let item_display = format_fill_with_item(table, column, &type_info, action_type); println!("{}", item_display); format_fill_with_prompt(table, column) @@ -128,6 +135,7 @@ where &item.table, &item.column, item.column_type.as_ref(), + item.default_value.as_ref(), item.action_type, ); @@ -720,15 +728,30 @@ mod tests { } #[test] - fn test_format_type_info_with_some() { - let column_type = Some("Integer".to_string()); - let result = format_type_info(column_type.as_ref()); - assert_eq!(result, " (Integer)"); + fn test_format_type_info_with_type_and_default() { + let column_type = Some("integer".to_string()); + let default_value = Some("0".to_string()); + let result = format_type_info(column_type.as_ref(), default_value.as_ref()); + assert_eq!(result, " (integer, default: 0)"); + } + + #[test] + fn test_format_type_info_with_type_only() { + let column_type = Some("text".to_string()); + let result = format_type_info(column_type.as_ref(), None); + assert_eq!(result, " (text)"); + } + + #[test] + fn test_format_type_info_with_default_only() { + let default_value = Some("0".to_string()); + let result = format_type_info(None, default_value.as_ref()); + assert_eq!(result, " (default: 0)"); } #[test] fn test_format_type_info_with_none() { - let result = format_type_info(None); + let result = format_type_info(None, None); assert_eq!(result, ""); } @@ -766,7 +789,8 @@ mod tests { let prompt = print_fill_with_item_and_get_prompt( "users", "email", - Some(&"Text".to_string()), + Some(&"text".to_string()), + Some(&"''".to_string()), "AddColumn", ); assert!(prompt.contains("Enter fill value for")); @@ -777,12 +801,26 @@ mod tests { #[test] fn test_print_fill_with_item_and_get_prompt_no_type() { let prompt = - print_fill_with_item_and_get_prompt("orders", "status", None, "ModifyColumnNullable"); + print_fill_with_item_and_get_prompt("orders", "status", None, None, "ModifyColumnNullable"); assert!(prompt.contains("Enter fill value for")); assert!(prompt.contains("orders")); assert!(prompt.contains("status")); } + #[test] + fn test_print_fill_with_item_and_get_prompt_with_default() { + let prompt = print_fill_with_item_and_get_prompt( + "users", + "age", + Some(&"integer".to_string()), + Some(&"0".to_string()), + "AddColumn", + ); + assert!(prompt.contains("Enter fill value for")); + assert!(prompt.contains("users")); + assert!(prompt.contains("age")); + } + #[test] fn test_print_fill_with_header() { // Just verify it doesn't panic - output goes to stdout @@ -804,7 +842,8 @@ mod tests { table: "users".to_string(), column: "email".to_string(), action_type: "AddColumn", - column_type: Some("Text".to_string()), + column_type: Some("text".to_string()), + default_value: Some("''".to_string()), }]; let mut fill_values = HashMap::new(); @@ -832,7 +871,8 @@ mod tests { table: "users".to_string(), column: "email".to_string(), action_type: "AddColumn", - column_type: Some("Text".to_string()), + column_type: Some("text".to_string()), + default_value: Some("''".to_string()), }, FillWithRequired { action_index: 1, @@ -840,6 +880,7 @@ mod tests { column: "status".to_string(), action_type: "ModifyColumnNullable", column_type: None, + default_value: None, }, ]; @@ -898,7 +939,8 @@ mod tests { table: "users".to_string(), column: "email".to_string(), action_type: "AddColumn", - column_type: Some("Text".to_string()), + column_type: Some("text".to_string()), + default_value: Some("''".to_string()), }]; let mut fill_values = HashMap::new(); diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 29edf4f..fc47514 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -108,6 +108,24 @@ impl ColumnType { base } } + + /// Convert column type to human-readable display string (for CLI prompts) + /// Examples: "integer", "text", "varchar(255)", "numeric(10,2)" + pub fn to_display_string(&self) -> String { + match self { + ColumnType::Simple(ty) => ty.to_display_string(), + ColumnType::Complex(ty) => ty.to_display_string(), + } + } + + /// Get the default fill value for this column type (for CLI prompts) + /// Returns None if no sensible default exists for the type + pub fn default_fill_value(&self) -> Option<&'static str> { + match self { + ColumnType::Simple(ty) => ty.default_fill_value(), + ColumnType::Complex(ty) => ty.default_fill_value(), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -159,6 +177,45 @@ impl SimpleColumnType { SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt ) } + + /// Convert to human-readable display string + pub fn to_display_string(&self) -> String { + match self { + SimpleColumnType::SmallInt => "smallint".to_string(), + SimpleColumnType::Integer => "integer".to_string(), + SimpleColumnType::BigInt => "bigint".to_string(), + SimpleColumnType::Real => "real".to_string(), + SimpleColumnType::DoublePrecision => "double precision".to_string(), + SimpleColumnType::Text => "text".to_string(), + SimpleColumnType::Boolean => "boolean".to_string(), + SimpleColumnType::Date => "date".to_string(), + SimpleColumnType::Time => "time".to_string(), + SimpleColumnType::Timestamp => "timestamp".to_string(), + SimpleColumnType::Timestamptz => "timestamptz".to_string(), + SimpleColumnType::Interval => "interval".to_string(), + SimpleColumnType::Bytea => "bytea".to_string(), + SimpleColumnType::Uuid => "uuid".to_string(), + SimpleColumnType::Json => "json".to_string(), + SimpleColumnType::Inet => "inet".to_string(), + SimpleColumnType::Cidr => "cidr".to_string(), + SimpleColumnType::Macaddr => "macaddr".to_string(), + SimpleColumnType::Xml => "xml".to_string(), + } + } + + /// Get the default fill value for this type + /// Returns None if no sensible default exists + pub fn default_fill_value(&self) -> Option<&'static str> { + match self { + SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt => { + Some("0") + } + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => Some("0.0"), + SimpleColumnType::Boolean => Some("false"), + SimpleColumnType::Text => Some("''"), + _ => None, + } + } } /// Integer enum variant with name and numeric value @@ -243,6 +300,37 @@ pub enum ComplexColumnType { Enum { name: String, values: EnumValues }, } +impl ComplexColumnType { + /// Convert to human-readable display string + pub fn to_display_string(&self) -> String { + match self { + 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.to_lowercase(), + ComplexColumnType::Enum { name, values } => { + if values.is_integer() { + format!("enum<{}> (integer)", name) + } else { + format!("enum<{}>", name) + } + } + } + } + + /// Get the default fill value for this type + /// Returns None if no sensible default exists + pub fn default_fill_value(&self) -> Option<&'static str> { + match self { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => Some("''"), + ComplexColumnType::Numeric { .. } => Some("0"), + _ => None, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -582,4 +670,170 @@ mod tests { }); assert!(string_enum.requires_migration(&int_enum)); } + + // Tests for to_display_string + #[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::Inet, "inet")] + #[case(SimpleColumnType::Cidr, "cidr")] + #[case(SimpleColumnType::Macaddr, "macaddr")] + #[case(SimpleColumnType::Xml, "xml")] + fn test_simple_column_type_to_display_string( + #[case] column_type: SimpleColumnType, + #[case] expected: &str, + ) { + assert_eq!(column_type.to_display_string(), expected); + } + + #[test] + fn test_complex_column_type_to_display_string_varchar() { + let ty = ComplexColumnType::Varchar { length: 255 }; + assert_eq!(ty.to_display_string(), "varchar(255)"); + } + + #[test] + fn test_complex_column_type_to_display_string_numeric() { + let ty = ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }; + assert_eq!(ty.to_display_string(), "numeric(10,2)"); + } + + #[test] + fn test_complex_column_type_to_display_string_char() { + let ty = ComplexColumnType::Char { length: 5 }; + assert_eq!(ty.to_display_string(), "char(5)"); + } + + #[test] + fn test_complex_column_type_to_display_string_custom() { + let ty = ComplexColumnType::Custom { + custom_type: "TSVECTOR".into(), + }; + assert_eq!(ty.to_display_string(), "tsvector"); + } + + #[test] + fn test_complex_column_type_to_display_string_string_enum() { + let ty = ComplexColumnType::Enum { + name: "user_status".into(), + values: EnumValues::String(vec!["active".into(), "inactive".into()]), + }; + assert_eq!(ty.to_display_string(), "enum"); + } + + #[test] + fn test_complex_column_type_to_display_string_integer_enum() { + let ty = ComplexColumnType::Enum { + name: "priority".into(), + values: EnumValues::Integer(vec![ + NumValue { + name: "Low".into(), + value: 0, + }, + NumValue { + name: "High".into(), + value: 10, + }, + ]), + }; + assert_eq!(ty.to_display_string(), "enum (integer)"); + } + + #[test] + fn test_column_type_to_display_string_simple() { + let ty = ColumnType::Simple(SimpleColumnType::Integer); + assert_eq!(ty.to_display_string(), "integer"); + } + + #[test] + fn test_column_type_to_display_string_complex() { + let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }); + assert_eq!(ty.to_display_string(), "varchar(100)"); + } + + // Tests for default_fill_value + #[rstest] + #[case(SimpleColumnType::SmallInt, Some("0"))] + #[case(SimpleColumnType::Integer, Some("0"))] + #[case(SimpleColumnType::BigInt, Some("0"))] + #[case(SimpleColumnType::Real, Some("0.0"))] + #[case(SimpleColumnType::DoublePrecision, Some("0.0"))] + #[case(SimpleColumnType::Boolean, Some("false"))] + #[case(SimpleColumnType::Text, Some("''"))] + #[case(SimpleColumnType::Date, None)] + #[case(SimpleColumnType::Time, None)] + #[case(SimpleColumnType::Timestamp, None)] + #[case(SimpleColumnType::Uuid, None)] + fn test_simple_column_type_default_fill_value( + #[case] column_type: SimpleColumnType, + #[case] expected: Option<&str>, + ) { + assert_eq!(column_type.default_fill_value(), expected); + } + + #[test] + fn test_complex_column_type_default_fill_value_varchar() { + let ty = ComplexColumnType::Varchar { length: 255 }; + assert_eq!(ty.default_fill_value(), Some("''")); + } + + #[test] + fn test_complex_column_type_default_fill_value_char() { + let ty = ComplexColumnType::Char { length: 1 }; + assert_eq!(ty.default_fill_value(), Some("''")); + } + + #[test] + fn test_complex_column_type_default_fill_value_numeric() { + let ty = ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }; + assert_eq!(ty.default_fill_value(), Some("0")); + } + + #[test] + fn test_complex_column_type_default_fill_value_custom() { + let ty = ComplexColumnType::Custom { + custom_type: "MONEY".into(), + }; + assert_eq!(ty.default_fill_value(), None); + } + + #[test] + fn test_complex_column_type_default_fill_value_enum() { + let ty = ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["active".into()]), + }; + assert_eq!(ty.default_fill_value(), None); + } + + #[test] + fn test_column_type_default_fill_value_simple() { + let ty = ColumnType::Simple(SimpleColumnType::Integer); + assert_eq!(ty.default_fill_value(), Some("0")); + } + + #[test] + fn test_column_type_default_fill_value_complex() { + let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }); + assert_eq!(ty.default_fill_value(), Some("''")); + } } diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index e0e7a1a..56bede7 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -402,6 +402,8 @@ pub struct FillWithRequired { pub action_type: &'static str, /// Column type (for display purposes). pub column_type: Option, + /// Default fill value hint for this column type. + pub default_value: Option, } /// Find all actions in a migration plan that require fill_with values. @@ -423,7 +425,8 @@ pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec { table: table.clone(), column: column.name.clone(), action_type: "AddColumn", - column_type: Some(format!("{:?}", column.r#type)), + column_type: Some(column.r#type.to_display_string()), + default_value: column.r#type.default_fill_value().map(String::from), }); } } @@ -441,6 +444,7 @@ pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec { column: column.clone(), action_type: "ModifyColumnNullable", column_type: None, + default_value: None, }); } } From 5c8371f4c7cf5939e5ce7364ed5e0bb0fb84dd61 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Jan 2026 17:05:32 +0900 Subject: [PATCH 2/4] Fix lint --- crates/vespertide-cli/src/commands/revision.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index 4440f4b..5a4ee22 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -800,8 +800,13 @@ mod tests { #[test] fn test_print_fill_with_item_and_get_prompt_no_type() { - let prompt = - print_fill_with_item_and_get_prompt("orders", "status", None, None, "ModifyColumnNullable"); + let prompt = print_fill_with_item_and_get_prompt( + "orders", + "status", + None, + None, + "ModifyColumnNullable", + ); assert!(prompt.contains("Enter fill value for")); assert!(prompt.contains("orders")); assert!(prompt.contains("status")); From d42f8a396435f0ec39bed0aa003d6b99d9733e57 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Jan 2026 17:21:23 +0900 Subject: [PATCH 3/4] Add test case --- .../vespertide-core/src/schema/constraint.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/vespertide-core/src/schema/constraint.rs b/crates/vespertide-core/src/schema/constraint.rs index 5273e92..360816d 100644 --- a/crates/vespertide-core/src/schema/constraint.rs +++ b/crates/vespertide-core/src/schema/constraint.rs @@ -52,3 +52,62 @@ impl TableConstraint { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_columns_primary_key() { + let pk = TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into(), "tenant_id".into()], + }; + assert_eq!(pk.columns().len(), 2); + assert_eq!(pk.columns()[0], "id"); + assert_eq!(pk.columns()[1], "tenant_id"); + } + + #[test] + fn test_columns_unique() { + let unique = TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }; + assert_eq!(unique.columns().len(), 1); + assert_eq!(unique.columns()[0], "email"); + } + + #[test] + fn test_columns_foreign_key() { + let fk = 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, + }; + assert_eq!(fk.columns().len(), 1); + assert_eq!(fk.columns()[0], "user_id"); + } + + #[test] + fn test_columns_index() { + let idx = TableConstraint::Index { + name: Some("ix_created_at".into()), + columns: vec!["created_at".into()], + }; + assert_eq!(idx.columns().len(), 1); + assert_eq!(idx.columns()[0], "created_at"); + } + + #[test] + fn test_columns_check_returns_empty() { + let check = TableConstraint::Check { + name: "check_positive".into(), + expr: "amount > 0".into(), + }; + assert!(check.columns().is_empty()); + } +} From df88ae038fd88824a6094633214e8381548b2f86 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Jan 2026 17:56:53 +0900 Subject: [PATCH 4/4] Fix CI: exclude app example from tarpaulin coverage --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a5389f6..717d3c2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -49,7 +49,7 @@ jobs: echo 'merge_derives = true' >> .rustfmt.toml echo 'use_small_heuristics = "Default"' >> .rustfmt.toml cargo fmt - cargo tarpaulin --out Lcov Stdout + cargo tarpaulin --out Lcov Stdout --workspace --exclude app - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: