diff --git a/.changepacks/changepack_log_S-V1v7UFYiEf3XeW_9d1F.json b/.changepacks/changepack_log_S-V1v7UFYiEf3XeW_9d1F.json new file mode 100644 index 0000000..5386dbc --- /dev/null +++ b/.changepacks/changepack_log_S-V1v7UFYiEf3XeW_9d1F.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch"},"note":"Add prefix option","date":"2026-01-21T09:42:55.945270800Z"} \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 717d3c2..1bcdf3b 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 --workspace --exclude app + cargo tarpaulin --engine llvm --out Lcov Stdout --workspace --exclude app - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: diff --git a/crates/vespertide-cli/src/commands/log.rs b/crates/vespertide-cli/src/commands/log.rs index d119c4b..f877405 100644 --- a/crates/vespertide-cli/src/commands/log.rs +++ b/crates/vespertide-cli/src/commands/log.rs @@ -15,18 +15,22 @@ pub fn cmd_log(backend: DatabaseBackend) -> Result<()> { return Ok(()); } + // Apply prefix to all migration plans + let prefix = config.prefix(); + let prefixed_plans: Vec<_> = plans.into_iter().map(|p| p.with_prefix(prefix)).collect(); + println!( "{} {} {}", "Migrations".bright_cyan().bold(), "(oldest -> newest):".bright_white(), - plans.len().to_string().bright_yellow().bold() + prefixed_plans.len().to_string().bright_yellow().bold() ); println!(); // Build baseline schema incrementally as we iterate through migrations let mut baseline_schema = Vec::new(); - for plan in &plans { + for plan in &prefixed_plans { println!( "{} {}", "Version:".bright_cyan().bold(), diff --git a/crates/vespertide-cli/src/commands/sql.rs b/crates/vespertide-cli/src/commands/sql.rs index c03bd06..ef406af 100644 --- a/crates/vespertide-cli/src/commands/sql.rs +++ b/crates/vespertide-cli/src/commands/sql.rs @@ -10,15 +10,24 @@ pub fn cmd_sql(backend: DatabaseBackend) -> Result<()> { let current_models = load_models(&config)?; let applied_plans = load_migrations(&config)?; - // Reconstruct the baseline schema from applied migrations - let baseline_schema = schema_from_plans(&applied_plans) + // Reconstruct the baseline schema from applied migrations (with prefix applied) + let prefix = config.prefix(); + let prefixed_plans: Vec<_> = applied_plans + .into_iter() + .map(|p| p.with_prefix(prefix)) + .collect(); + let baseline_schema = schema_from_plans(&prefixed_plans) .map_err(|e| anyhow::anyhow!("failed to reconstruct schema: {}", e))?; // Plan next migration using the pre-computed baseline - let plan = plan_next_migration_with_baseline(¤t_models, &applied_plans, &baseline_schema) - .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + let plan = + plan_next_migration_with_baseline(¤t_models, &prefixed_plans, &baseline_schema) + .map_err(|e| anyhow::anyhow!("planning error: {}", e))?; + + // Apply prefix to the new plan for SQL generation + let prefixed_plan = plan.with_prefix(prefix); - emit_sql(&plan, backend, &baseline_schema) + emit_sql(&prefixed_plan, backend, &baseline_schema) } fn emit_sql( diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index a5c4a38..f3fafaa 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -83,6 +83,10 @@ pub struct VespertideConfig { /// SeaORM-specific export configuration. #[serde(default)] pub seaorm: SeaOrmConfig, + /// Prefix to add to all table names (including migration version table). + /// Default: "" (no prefix) + #[serde(default)] + pub prefix: String, } fn default_model_export_dir() -> PathBuf { @@ -101,6 +105,7 @@ impl Default for VespertideConfig { migration_filename_pattern: default_migration_filename_pattern(), model_export_dir: default_model_export_dir(), seaorm: SeaOrmConfig::default(), + prefix: String::new(), } } } @@ -150,6 +155,20 @@ impl VespertideConfig { pub fn seaorm(&self) -> &SeaOrmConfig { &self.seaorm } + + /// Prefix to add to all table names. + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// Apply prefix to a table name. + pub fn apply_prefix(&self, table_name: &str) -> String { + if self.prefix.is_empty() { + table_name.to_string() + } else { + format!("{}{}", self.prefix, table_name) + } + } } #[cfg(test)] @@ -173,5 +192,26 @@ mod tests { vec!["vespera::Schema".to_string()] ); assert!(config.seaorm.extra_model_derives.is_empty()); + assert_eq!(config.prefix, ""); + } + + #[test] + fn test_vespertide_config_prefix() { + let config = VespertideConfig { + prefix: "myapp_".to_string(), + ..Default::default() + }; + + assert_eq!(config.prefix(), "myapp_"); + assert_eq!(config.apply_prefix("users"), "myapp_users"); + assert_eq!(config.apply_prefix("posts"), "myapp_posts"); + } + + #[test] + fn test_vespertide_config_empty_prefix() { + let config = VespertideConfig::default(); + + assert_eq!(config.prefix(), ""); + assert_eq!(config.apply_prefix("users"), "users"); } } diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index ee2c4ef..d8a4ecf 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -80,6 +80,123 @@ pub enum MigrationAction { }, } +impl MigrationPlan { + /// Apply a prefix to all table names in the migration plan. + /// This modifies all table references in all actions. + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + Self { + actions: self + .actions + .into_iter() + .map(|action| action.with_prefix(prefix)) + .collect(), + ..self + } + } +} + +impl MigrationAction { + /// Apply a prefix to all table names in this action. + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + match self { + MigrationAction::CreateTable { + table, + columns, + constraints, + } => MigrationAction::CreateTable { + table: format!("{}{}", prefix, table), + columns, + constraints: constraints + .into_iter() + .map(|c| c.with_prefix(prefix)) + .collect(), + }, + MigrationAction::DeleteTable { table } => MigrationAction::DeleteTable { + table: format!("{}{}", prefix, table), + }, + MigrationAction::AddColumn { + table, + column, + fill_with, + } => MigrationAction::AddColumn { + table: format!("{}{}", prefix, table), + column, + fill_with, + }, + MigrationAction::RenameColumn { table, from, to } => MigrationAction::RenameColumn { + table: format!("{}{}", prefix, table), + from, + to, + }, + MigrationAction::DeleteColumn { table, column } => MigrationAction::DeleteColumn { + table: format!("{}{}", prefix, table), + column, + }, + MigrationAction::ModifyColumnType { + table, + column, + new_type, + } => MigrationAction::ModifyColumnType { + table: format!("{}{}", prefix, table), + column, + new_type, + }, + MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + } => MigrationAction::ModifyColumnNullable { + table: format!("{}{}", prefix, table), + column, + nullable, + fill_with, + }, + MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } => MigrationAction::ModifyColumnDefault { + table: format!("{}{}", prefix, table), + column, + new_default, + }, + MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } => MigrationAction::ModifyColumnComment { + table: format!("{}{}", prefix, table), + column, + new_comment, + }, + MigrationAction::AddConstraint { table, constraint } => { + MigrationAction::AddConstraint { + table: format!("{}{}", prefix, table), + constraint: constraint.with_prefix(prefix), + } + } + MigrationAction::RemoveConstraint { table, constraint } => { + MigrationAction::RemoveConstraint { + table: format!("{}{}", prefix, table), + constraint: constraint.with_prefix(prefix), + } + } + MigrationAction::RenameTable { from, to } => MigrationAction::RenameTable { + from: format!("{}{}", prefix, from), + to: format!("{}{}", prefix, to), + }, + MigrationAction::RawSql { sql } => MigrationAction::RawSql { sql }, + } + } +} + impl fmt::Display for MigrationAction { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -590,4 +707,324 @@ mod tests { // Should be truncated at 27 chars + "..." assert!(!result.contains("truncated in display")); } + + // Tests for with_prefix + #[test] + fn test_action_with_prefix_create_table() { + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![default_column()], + constraints: vec![TableConstraint::ForeignKey { + name: Some("fk_org".into()), + columns: vec!["org_id".into()], + ref_table: "organizations".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }], + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::CreateTable { + table, constraints, .. + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { + assert_eq!(ref_table.as_str(), "myapp_organizations"); + } + } else { + panic!("Expected CreateTable"); + } + } + + #[test] + fn test_action_with_prefix_delete_table() { + let action = MigrationAction::DeleteTable { + table: "users".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::DeleteTable { table } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + } else { + panic!("Expected DeleteTable"); + } + } + + #[test] + fn test_action_with_prefix_add_column() { + let action = MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(default_column()), + fill_with: None, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::AddColumn { table, .. } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + } else { + panic!("Expected AddColumn"); + } + } + + #[test] + fn test_action_with_prefix_rename_table() { + let action = MigrationAction::RenameTable { + from: "old_table".into(), + to: "new_table".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RenameTable { from, to } = prefixed { + assert_eq!(from.as_str(), "myapp_old_table"); + assert_eq!(to.as_str(), "myapp_new_table"); + } else { + panic!("Expected RenameTable"); + } + } + + #[test] + fn test_action_with_prefix_raw_sql_unchanged() { + let action = MigrationAction::RawSql { + sql: "SELECT * FROM users".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RawSql { sql } = prefixed { + // RawSql is not modified - user is responsible for table names + assert_eq!(sql, "SELECT * FROM users"); + } else { + panic!("Expected RawSql"); + } + } + + #[test] + fn test_action_with_prefix_empty_prefix() { + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }; + let prefixed = action.clone().with_prefix(""); + if let MigrationAction::CreateTable { table, .. } = prefixed { + assert_eq!(table.as_str(), "users"); + } + } + + #[test] + fn test_migration_plan_with_prefix() { + let plan = MigrationPlan { + comment: Some("test".into()), + created_at: None, + version: 1, + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![], + constraints: vec![], + }, + MigrationAction::CreateTable { + table: "posts".into(), + columns: vec![], + constraints: vec![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, + }], + }, + ], + }; + let prefixed = plan.with_prefix("myapp_"); + assert_eq!(prefixed.actions.len(), 2); + + if let MigrationAction::CreateTable { table, .. } = &prefixed.actions[0] { + assert_eq!(table.as_str(), "myapp_users"); + } + if let MigrationAction::CreateTable { + table, constraints, .. + } = &prefixed.actions[1] + { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = &constraints[0] { + assert_eq!(ref_table.as_str(), "myapp_users"); + } + } + } + + #[test] + fn test_action_with_prefix_rename_column() { + let action = MigrationAction::RenameColumn { + table: "users".into(), + from: "name".into(), + to: "full_name".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RenameColumn { table, from, to } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(from.as_str(), "name"); + assert_eq!(to.as_str(), "full_name"); + } else { + panic!("Expected RenameColumn"); + } + } + + #[test] + fn test_action_with_prefix_delete_column() { + let action = MigrationAction::DeleteColumn { + table: "users".into(), + column: "old_field".into(), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::DeleteColumn { table, column } = prefixed { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "old_field"); + } else { + panic!("Expected DeleteColumn"); + } + } + + #[test] + fn test_action_with_prefix_modify_column_type() { + let action = MigrationAction::ModifyColumnType { + table: "users".into(), + column: "age".into(), + new_type: ColumnType::Simple(SimpleColumnType::BigInt), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnType { + table, + column, + new_type, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "age"); + assert!(matches!( + new_type, + ColumnType::Simple(SimpleColumnType::BigInt) + )); + } else { + panic!("Expected ModifyColumnType"); + } + } + + #[test] + fn test_action_with_prefix_modify_column_nullable() { + let action = MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: Some("default@example.com".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnNullable { + table, + column, + nullable, + fill_with, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "email"); + assert!(!nullable); + assert_eq!(fill_with, Some("default@example.com".into())); + } else { + panic!("Expected ModifyColumnNullable"); + } + } + + #[test] + fn test_action_with_prefix_modify_column_default() { + let action = MigrationAction::ModifyColumnDefault { + table: "users".into(), + column: "status".into(), + new_default: Some("active".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnDefault { + table, + column, + new_default, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "status"); + assert_eq!(new_default, Some("active".into())); + } else { + panic!("Expected ModifyColumnDefault"); + } + } + + #[test] + fn test_action_with_prefix_modify_column_comment() { + let action = MigrationAction::ModifyColumnComment { + table: "users".into(), + column: "bio".into(), + new_comment: Some("User biography".into()), + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::ModifyColumnComment { + table, + column, + new_comment, + } = prefixed + { + assert_eq!(table.as_str(), "myapp_users"); + assert_eq!(column.as_str(), "bio"); + assert_eq!(new_comment, Some("User biography".into())); + } else { + panic!("Expected ModifyColumnComment"); + } + } + + #[test] + fn test_action_with_prefix_add_constraint() { + let action = MigrationAction::AddConstraint { + table: "posts".into(), + constraint: 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, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::AddConstraint { table, constraint } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = constraint { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint"); + } + } else { + panic!("Expected AddConstraint"); + } + } + + #[test] + fn test_action_with_prefix_remove_constraint() { + let action = MigrationAction::RemoveConstraint { + table: "posts".into(), + constraint: 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, + }, + }; + let prefixed = action.with_prefix("myapp_"); + if let MigrationAction::RemoveConstraint { table, constraint } = prefixed { + assert_eq!(table.as_str(), "myapp_posts"); + if let TableConstraint::ForeignKey { ref_table, .. } = constraint { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey constraint"); + } + } else { + panic!("Expected RemoveConstraint"); + } + } } diff --git a/crates/vespertide-core/src/schema/constraint.rs b/crates/vespertide-core/src/schema/constraint.rs index 360816d..e825e8a 100644 --- a/crates/vespertide-core/src/schema/constraint.rs +++ b/crates/vespertide-core/src/schema/constraint.rs @@ -51,6 +51,33 @@ impl TableConstraint { TableConstraint::Check { .. } => &[], } } + + /// Apply a prefix to referenced table names in this constraint. + /// Only affects ForeignKey constraints (which reference other tables). + pub fn with_prefix(self, prefix: &str) -> Self { + if prefix.is_empty() { + return self; + } + match self { + TableConstraint::ForeignKey { + name, + columns, + ref_table, + ref_columns, + on_delete, + on_update, + } => TableConstraint::ForeignKey { + name, + columns, + ref_table: format!("{}{}", prefix, ref_table), + ref_columns, + on_delete, + on_update, + }, + // Other constraints don't reference external tables + other => other, + } + } } #[cfg(test)] @@ -110,4 +137,67 @@ mod tests { }; assert!(check.columns().is_empty()); } + + #[test] + fn test_with_prefix_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, + }; + let prefixed = fk.with_prefix("myapp_"); + if let TableConstraint::ForeignKey { ref_table, .. } = prefixed { + assert_eq!(ref_table.as_str(), "myapp_users"); + } else { + panic!("Expected ForeignKey"); + } + } + + #[test] + fn test_with_prefix_non_fk_unchanged() { + let pk = TableConstraint::PrimaryKey { + auto_increment: false, + columns: vec!["id".into()], + }; + let prefixed = pk.clone().with_prefix("myapp_"); + assert_eq!(pk, prefixed); + + let unique = TableConstraint::Unique { + name: Some("uq_email".into()), + columns: vec!["email".into()], + }; + let prefixed = unique.clone().with_prefix("myapp_"); + assert_eq!(unique, prefixed); + + let idx = TableConstraint::Index { + name: Some("ix_created_at".into()), + columns: vec!["created_at".into()], + }; + let prefixed = idx.clone().with_prefix("myapp_"); + assert_eq!(idx, prefixed); + + let check = TableConstraint::Check { + name: "check_positive".into(), + expr: "amount > 0".into(), + }; + let prefixed = check.clone().with_prefix("myapp_"); + assert_eq!(check, prefixed); + } + + #[test] + fn test_with_prefix_empty_prefix() { + 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, + }; + let prefixed = fk.clone().with_prefix(""); + assert_eq!(fk, prefixed); + } } diff --git a/crates/vespertide-exporter/src/sqlalchemy/mod.rs b/crates/vespertide-exporter/src/sqlalchemy/mod.rs index 9e65e7a..126f3f6 100644 --- a/crates/vespertide-exporter/src/sqlalchemy/mod.rs +++ b/crates/vespertide-exporter/src/sqlalchemy/mod.rs @@ -24,74 +24,82 @@ impl<'a> UsedTypes<'a> { } match col_type { - ColumnType::Simple(ty) => match ty { - SimpleColumnType::SmallInt => { - self.sa_types.insert("SmallInteger"); + ColumnType::Simple(ty) => self.add_simple_type(ty), + ColumnType::Complex(ty) => self.add_complex_type(ty), + } + } + + fn add_simple_type(&mut self, ty: &SimpleColumnType) { + match ty { + SimpleColumnType::SmallInt => { + self.sa_types.insert("SmallInteger"); + } + SimpleColumnType::Integer => { + self.sa_types.insert("Integer"); + } + SimpleColumnType::BigInt => { + self.sa_types.insert("BigInteger"); + } + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => { + self.sa_types.insert("Float"); + } + SimpleColumnType::Text => { + self.sa_types.insert("Text"); + } + SimpleColumnType::Boolean => { + self.sa_types.insert("Boolean"); + } + SimpleColumnType::Date => { + self.sa_types.insert("Date"); + self.datetime_types.insert("date"); + } + SimpleColumnType::Time => { + self.sa_types.insert("Time"); + self.datetime_types.insert("time"); + } + SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => { + self.sa_types.insert("DateTime"); + self.datetime_types.insert("datetime"); + } + SimpleColumnType::Interval => { + self.sa_types.insert("Interval"); + } + SimpleColumnType::Bytea => { + self.sa_types.insert("LargeBinary"); + } + SimpleColumnType::Uuid => { + self.sa_types.insert("Uuid"); + self.needs_uuid = true; + } + SimpleColumnType::Json => { + self.sa_types.insert("JSON"); + } + SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { + self.sa_types.insert("String"); + } + SimpleColumnType::Xml => { + self.sa_types.insert("Text"); + } + } + } + + fn add_complex_type(&mut self, ty: &ComplexColumnType) { + match ty { + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { + self.sa_types.insert("String"); + } + ComplexColumnType::Numeric { .. } => { + self.sa_types.insert("Numeric"); + self.needs_decimal = true; + } + ComplexColumnType::Custom { .. } => {} + ComplexColumnType::Enum { values, .. } => match values { + EnumValues::String(_) => { + self.sa_types.insert("Enum"); } - SimpleColumnType::Integer => { + EnumValues::Integer(_) => { self.sa_types.insert("Integer"); } - SimpleColumnType::BigInt => { - self.sa_types.insert("BigInteger"); - } - SimpleColumnType::Real | SimpleColumnType::DoublePrecision => { - self.sa_types.insert("Float"); - } - SimpleColumnType::Text => { - self.sa_types.insert("Text"); - } - SimpleColumnType::Boolean => { - self.sa_types.insert("Boolean"); - } - SimpleColumnType::Date => { - self.sa_types.insert("Date"); - self.datetime_types.insert("date"); - } - SimpleColumnType::Time => { - self.sa_types.insert("Time"); - self.datetime_types.insert("time"); - } - SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => { - self.sa_types.insert("DateTime"); - self.datetime_types.insert("datetime"); - } - SimpleColumnType::Interval => { - self.sa_types.insert("Interval"); - } - SimpleColumnType::Bytea => { - self.sa_types.insert("LargeBinary"); - } - SimpleColumnType::Uuid => { - self.sa_types.insert("Uuid"); - self.needs_uuid = true; - } - SimpleColumnType::Json => { - self.sa_types.insert("JSON"); - } - SimpleColumnType::Inet | SimpleColumnType::Cidr | SimpleColumnType::Macaddr => { - self.sa_types.insert("String"); - } - SimpleColumnType::Xml => { - self.sa_types.insert("Text"); - } - }, - ColumnType::Complex(ty) => match ty { - ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => { - self.sa_types.insert("String"); - } - ComplexColumnType::Numeric { .. } => { - self.sa_types.insert("Numeric"); - self.needs_decimal = true; - } - ComplexColumnType::Custom { .. } => {} - ComplexColumnType::Enum { values, .. } => match values { - EnumValues::String(_) => { - self.sa_types.insert("Enum"); - } - EnumValues::Integer(_) => { - self.sa_types.insert("Integer"); - } - }, }, } } @@ -1352,4 +1360,128 @@ mod tests { used.add_column_type(&ColumnType::Simple(SimpleColumnType::Integer), true); assert!(used.needs_optional); } + + /// Comprehensive test that exercises ALL branches of add_column_type in a single test. + /// This ensures tarpaulin sees all branches as covered. + #[test] + fn test_used_types_all_branches_comprehensive() { + let mut used = UsedTypes::default(); + + // Simple types - each branch + used.add_column_type(&ColumnType::Simple(SimpleColumnType::SmallInt), false); + assert!(used.sa_types.contains("SmallInteger")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Integer), false); + assert!(used.sa_types.contains("Integer")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::BigInt), false); + assert!(used.sa_types.contains("BigInteger")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Real), false); + assert!(used.sa_types.contains("Float")); + + used.add_column_type( + &ColumnType::Simple(SimpleColumnType::DoublePrecision), + false, + ); + // Float already added by Real + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Text), false); + assert!(used.sa_types.contains("Text")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Boolean), false); + assert!(used.sa_types.contains("Boolean")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Date), false); + assert!(used.sa_types.contains("Date")); + assert!(used.datetime_types.contains("date")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Time), false); + assert!(used.sa_types.contains("Time")); + assert!(used.datetime_types.contains("time")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Timestamp), false); + assert!(used.sa_types.contains("DateTime")); + assert!(used.datetime_types.contains("datetime")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Timestamptz), false); + // DateTime already added + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Interval), false); + assert!(used.sa_types.contains("Interval")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Bytea), false); + assert!(used.sa_types.contains("LargeBinary")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Uuid), false); + assert!(used.sa_types.contains("Uuid")); + assert!(used.needs_uuid); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Json), false); + assert!(used.sa_types.contains("JSON")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Inet), false); + assert!(used.sa_types.contains("String")); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Cidr), false); + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Macaddr), false); + + used.add_column_type(&ColumnType::Simple(SimpleColumnType::Xml), false); + // Text already added + + // Complex types + used.add_column_type( + &ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }), + false, + ); + used.add_column_type( + &ColumnType::Complex(ComplexColumnType::Char { length: 10 }), + false, + ); + + used.add_column_type( + &ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }), + false, + ); + assert!(used.sa_types.contains("Numeric")); + assert!(used.needs_decimal); + + used.add_column_type( + &ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "FOO".into(), + }), + false, + ); + // Custom doesn't add any type + + used.add_column_type( + &ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["a".into()]), + }), + false, + ); + assert!(used.sa_types.contains("Enum")); + + used.add_column_type( + &ColumnType::Complex(ComplexColumnType::Enum { + name: "priority".into(), + values: EnumValues::Integer(vec![NumValue { + name: "Low".into(), + value: 0, + }]), + }), + false, + ); + // Integer already added + + // Test nullable + let mut used2 = UsedTypes::default(); + assert!(!used2.needs_optional); + used2.add_column_type(&ColumnType::Simple(SimpleColumnType::Integer), true); + assert!(used2.needs_optional); + } } diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 5190885..6a49a3f 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -1,10 +1,15 @@ // MigrationOptions and MigrationError are now in vespertide-core +use std::env; +use std::path::PathBuf; + use proc_macro::TokenStream; use quote::quote; use syn::parse::{Parse, ParseStream}; use syn::{Expr, Ident, Token}; -use vespertide_loader::{load_migrations_at_compile_time, load_models_at_compile_time}; +use vespertide_loader::{ + load_config_or_default, load_migrations_at_compile_time, load_models_at_compile_time, +}; use vespertide_planner::apply_action; use vespertide_query::{DatabaseBackend, build_plan_queries}; @@ -180,9 +185,32 @@ pub(crate) fn vespertide_migration_impl( Err(e) => return e.to_compile_error(), }; let pool = &input.pool; + + // Get project root from CARGO_MANIFEST_DIR (same as load_migrations_at_compile_time) + let project_root = match env::var("CARGO_MANIFEST_DIR") { + Ok(dir) => Some(PathBuf::from(dir)), + Err(_) => None, + }; + + // Load config to get prefix + let config = match load_config_or_default(project_root) { + Ok(config) => config, + #[cfg(not(tarpaulin_include))] + Err(e) => { + return syn::Error::new( + proc_macro2::Span::call_site(), + format!("Failed to load config at compile time: {}", e), + ) + .to_compile_error(); + } + }; + let prefix = config.prefix(); + + // Apply prefix to version_table if not explicitly provided let version_table = input .version_table - .unwrap_or_else(|| "vespertide_version".to_string()); + .map(|vt| config.apply_prefix(&vt)) + .unwrap_or_else(|| config.apply_prefix("vespertide_version")); // Load migration files and build SQL at compile time let migrations = match load_migrations_at_compile_time() { @@ -207,13 +235,15 @@ pub(crate) fn vespertide_migration_impl( } }; - // Build SQL for each migration using incremental baseline schema + // Apply prefix to migrations and build SQL using incremental baseline schema let mut baseline_schema = Vec::new(); let mut migration_blocks = Vec::new(); #[cfg(not(tarpaulin_include))] for migration in &migrations { - match build_migration_block(migration, &mut baseline_schema) { + // Apply prefix to migration table names + let prefixed_migration = migration.clone().with_prefix(prefix); + match build_migration_block(&prefixed_migration, &mut baseline_schema) { Ok(block) => migration_blocks.push(block), Err(e) => { return syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error(); diff --git a/crates/vespertide-query/tests/table_prefixed_enum_test.rs b/crates/vespertide-query/tests/table_prefixed_enum_test.rs index 6d40d7a..5a217da 100644 --- a/crates/vespertide-query/tests/table_prefixed_enum_test.rs +++ b/crates/vespertide-query/tests/table_prefixed_enum_test.rs @@ -1,143 +1,148 @@ -use vespertide_core::{ - ColumnDef, ColumnType, ComplexColumnType, EnumValues, MigrationAction, MigrationPlan, - SimpleColumnType, -}; -use vespertide_query::{DatabaseBackend, build_plan_queries}; - -#[test] -fn test_table_prefixed_enum_names() { - // Test that enum types are created with table-prefixed names to avoid conflicts - let plan = MigrationPlan { - version: 1, - comment: Some("Test enum naming".into()), - created_at: None, - actions: vec![ - // Create users table with status enum - MigrationAction::CreateTable { - table: "users".into(), - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: Some( - vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true), - ), - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "status".into(), - r#type: ColumnType::Complex(ComplexColumnType::Enum { +#[cfg(test)] +mod test_utils { + use vespertide_core::{ + ColumnDef, ColumnType, ComplexColumnType, EnumValues, MigrationAction, MigrationPlan, + SimpleColumnType, + }; + use vespertide_query::{DatabaseBackend, build_plan_queries}; + #[test] + fn test_table_prefixed_enum_names() { + // Test that enum types are created with table-prefixed names to avoid conflicts + let plan = MigrationPlan { + version: 1, + comment: Some("Test enum naming".into()), + created_at: None, + actions: vec![ + // Create users table with status enum + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: Some( + vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true), + ), + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { name: "status".into(), - values: EnumValues::String(vec!["active".into(), "inactive".into()]), - }), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }, - // Create orders table with status enum (same name, different table) - MigrationAction::CreateTable { - table: "orders".into(), - columns: vec![ - ColumnDef { - name: "id".into(), - r#type: ColumnType::Simple(SimpleColumnType::Integer), - nullable: false, - default: None, - comment: None, - primary_key: Some( - vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true), - ), - unique: None, - index: None, - foreign_key: None, - }, - ColumnDef { - name: "status".into(), - r#type: ColumnType::Complex(ComplexColumnType::Enum { + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec![ + "active".into(), + "inactive".into(), + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }, + // Create orders table with status enum (same name, different table) + MigrationAction::CreateTable { + table: "orders".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: Some( + vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true), + ), + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { name: "status".into(), - values: EnumValues::String(vec![ - "pending".into(), - "shipped".into(), - "delivered".into(), - ]), - }), - nullable: false, - default: None, - comment: None, - primary_key: None, - unique: None, - index: None, - foreign_key: None, - }, - ], - constraints: vec![], - }, - ], - }; + r#type: ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }, + ], + }; - let queries = build_plan_queries(&plan, &[]).unwrap(); + let queries = build_plan_queries(&plan, &[]).unwrap(); - // Check users table enum type - let users_sql = &queries[0].postgres; - let create_users_enum = users_sql[0].build(DatabaseBackend::Postgres); - assert!( - create_users_enum.contains("CREATE TYPE \"users_status\""), - "Should create users_status enum type. Got: {}", - create_users_enum - ); - assert!( - create_users_enum.contains("'active', 'inactive'"), - "Should include user status values" - ); + // Check users table enum type + let users_sql = &queries[0].postgres; + let create_users_enum = users_sql[0].build(DatabaseBackend::Postgres); + assert!( + create_users_enum.contains("CREATE TYPE \"users_status\""), + "Should create users_status enum type. Got: {}", + create_users_enum + ); + assert!( + create_users_enum.contains("'active', 'inactive'"), + "Should include user status values" + ); - let create_users_table = users_sql[1].build(DatabaseBackend::Postgres); - assert!( - create_users_table.contains("users_status"), - "Users table should use users_status type. Got: {}", - create_users_table - ); + let create_users_table = users_sql[1].build(DatabaseBackend::Postgres); + assert!( + create_users_table.contains("users_status"), + "Users table should use users_status type. Got: {}", + create_users_table + ); - // Check orders table enum type - let orders_sql = &queries[1].postgres; - let create_orders_enum = orders_sql[0].build(DatabaseBackend::Postgres); - assert!( - create_orders_enum.contains("CREATE TYPE \"orders_status\""), - "Should create orders_status enum type. Got: {}", - create_orders_enum - ); - assert!( - create_orders_enum.contains("'pending', 'shipped', 'delivered'"), - "Should include order status values" - ); + // Check orders table enum type + let orders_sql = &queries[1].postgres; + let create_orders_enum = orders_sql[0].build(DatabaseBackend::Postgres); + assert!( + create_orders_enum.contains("CREATE TYPE \"orders_status\""), + "Should create orders_status enum type. Got: {}", + create_orders_enum + ); + assert!( + create_orders_enum.contains("'pending', 'shipped', 'delivered'"), + "Should include order status values" + ); - let create_orders_table = orders_sql[1].build(DatabaseBackend::Postgres); - assert!( - create_orders_table.contains("orders_status"), - "Orders table should use orders_status type. Got: {}", - create_orders_table - ); + let create_orders_table = orders_sql[1].build(DatabaseBackend::Postgres); + assert!( + create_orders_table.contains("orders_status"), + "Orders table should use orders_status type. Got: {}", + create_orders_table + ); - println!("\n=== Users Table SQL ==="); - for (i, q) in users_sql.iter().enumerate() { - println!("{}: {}", i + 1, q.build(DatabaseBackend::Postgres)); - } + println!("\n=== Users Table SQL ==="); + for (i, q) in users_sql.iter().enumerate() { + println!("{}: {}", i + 1, q.build(DatabaseBackend::Postgres)); + } - println!("\n=== Orders Table SQL ==="); - for (i, q) in orders_sql.iter().enumerate() { - println!("{}: {}", i + 1, q.build(DatabaseBackend::Postgres)); - } + println!("\n=== Orders Table SQL ==="); + for (i, q) in orders_sql.iter().enumerate() { + println!("{}: {}", i + 1, q.build(DatabaseBackend::Postgres)); + } - println!("\n✅ Table-prefixed enum names successfully prevent naming conflicts!"); + println!("\n✅ Table-prefixed enum names successfully prevent naming conflicts!"); + } } diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 7825085..050b59e 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -30,10 +30,16 @@ "modelsDir": { "type": "string" }, + "prefix": { + "description": "Prefix to add to all table names (including migration version table).\nDefault: \"\" (no prefix)", + "type": "string", + "default": "" + }, "seaorm": { "description": "SeaORM-specific export configuration.", "$ref": "#/$defs/SeaOrmConfig", "default": { + "enumNamingCase": "camel", "extraEnumDerives": [ "vespera::Schema" ], @@ -73,6 +79,11 @@ "description": "SeaORM-specific export configuration.", "type": "object", "properties": { + "enumNamingCase": { + "description": "Naming case for serde rename_all attribute on generated enums.\nDefault: `Camel` (generates `#[serde(rename_all = \"camelCase\")]`)", + "$ref": "#/$defs/NameCase", + "default": "camel" + }, "extraEnumDerives": { "description": "Additional derive macros to add to generated enum types.\nDefault: `[\"vespera::Schema\"]`", "type": "array", diff --git a/schemas/migration.schema.json b/schemas/migration.schema.json index 2a0c448..e435f5c 100644 --- a/schemas/migration.schema.json +++ b/schemas/migration.schema.json @@ -299,6 +299,11 @@ "type": "string" }, { + "description": "{ \"references\": \"table.column\", \"on_delete\": \"cascade\" }", + "$ref": "#/$defs/ReferenceSyntaxDef" + }, + { + "description": "{ \"ref_table\": \"table\", \"ref_columns\": [\"column\"], ... }", "$ref": "#/$defs/ForeignKeyDef" } ] @@ -655,6 +660,39 @@ "no_action" ] }, + "ReferenceSyntaxDef": { + "description": "Shorthand syntax for foreign key: { \"references\": \"table.column\", \"on_delete\": \"cascade\" }", + "type": "object", + "properties": { + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "references": { + "description": "Reference in \"table.column\" format", + "type": "string" + } + }, + "required": [ + "references" + ] + }, "SimpleColumnType": { "type": "string", "enum": [ @@ -673,7 +711,6 @@ "bytea", "uuid", "json", - "jsonb", "inet", "cidr", "macaddr", diff --git a/schemas/model.schema.json b/schemas/model.schema.json index bf4a0a6..03b9c58 100644 --- a/schemas/model.schema.json +++ b/schemas/model.schema.json @@ -27,8 +27,7 @@ }, "required": [ "name", - "columns", - "constraints" + "columns" ], "$defs": { "ColumnDef": { @@ -297,6 +296,11 @@ "type": "string" }, { + "description": "{ \"references\": \"table.column\", \"on_delete\": \"cascade\" }", + "$ref": "#/$defs/ReferenceSyntaxDef" + }, + { + "description": "{ \"ref_table\": \"table\", \"ref_columns\": [\"column\"], ... }", "$ref": "#/$defs/ForeignKeyDef" } ] @@ -347,6 +351,39 @@ "no_action" ] }, + "ReferenceSyntaxDef": { + "description": "Shorthand syntax for foreign key: { \"references\": \"table.column\", \"on_delete\": \"cascade\" }", + "type": "object", + "properties": { + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "references": { + "description": "Reference in \"table.column\" format", + "type": "string" + } + }, + "required": [ + "references" + ] + }, "SimpleColumnType": { "type": "string", "enum": [ @@ -365,7 +402,6 @@ "bytea", "uuid", "json", - "jsonb", "inet", "cidr", "macaddr",