diff --git a/.changepacks/changepack_log_-4qn2PLbAm6nN9DB7Ve5P.json b/.changepacks/changepack_log_-4qn2PLbAm6nN9DB7Ve5P.json new file mode 100644 index 0000000..82442a6 --- /dev/null +++ b/.changepacks/changepack_log_-4qn2PLbAm6nN9DB7Ve5P.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch"},"note":"Fix multiple unique","date":"2025-12-19T08:56:40.862336800Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_lpT0KZP7pkX1dLIT7rA_M.json b/.changepacks/changepack_log_lpT0KZP7pkX1dLIT7rA_M.json new file mode 100644 index 0000000..c3b1004 --- /dev/null +++ b/.changepacks/changepack_log_lpT0KZP7pkX1dLIT7rA_M.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch"},"note":"Fix enum export issue","date":"2025-12-19T08:56:26.871529700Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 9af0bc7..568e41f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2949,7 +2949,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.14" +version = "0.1.15" dependencies = [ "vespertide-core", "vespertide-macro", @@ -2957,7 +2957,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "assert_cmd", @@ -2981,7 +2981,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.14" +version = "0.1.15" dependencies = [ "clap", "serde", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.14" +version = "0.1.15" dependencies = [ "rstest", "schemars", @@ -3000,7 +3000,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.14" +version = "0.1.15" dependencies = [ "insta", "rstest", @@ -3010,7 +3010,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.14" +version = "0.1.15" dependencies = [ "anyhow", "rstest", @@ -3025,7 +3025,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.14" +version = "0.1.15" dependencies = [ "proc-macro2", "quote", @@ -3042,11 +3042,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.14" +version = "0.1.15" [[package]] name = "vespertide-planner" -version = "0.1.14" +version = "0.1.15" dependencies = [ "insta", "rstest", @@ -3057,7 +3057,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.14" +version = "0.1.15" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-core/src/schema/table.rs b/crates/vespertide-core/src/schema/table.rs index 16d7f7a..f21ec7d 100644 --- a/crates/vespertide-core/src/schema/table.rs +++ b/crates/vespertide-core/src/schema/table.rs @@ -100,87 +100,101 @@ impl TableDef { } } - // Process inline unique and index for each column + // Group columns by unique constraint name to create composite unique constraints + // Use same pattern as index grouping + let mut unique_groups: HashMap> = HashMap::new(); + let mut unique_order: Vec = Vec::new(); // Preserve order of first occurrence + for col in &self.columns { - // Handle inline unique if let Some(ref unique_val) = col.unique { match unique_val { StrOrBoolOrArray::Str(name) => { - let constraint_name = Some(name.clone()); - - // Check if this unique constraint already exists - let exists = constraints.iter().any(|c| { - if let TableConstraint::Unique { - name: c_name, - columns, - } = c - { - c_name.as_ref() == Some(name) - && columns.len() == 1 - && columns[0] == col.name - } else { - false - } - }); + // Named unique constraint - group by name for composite constraints + let unique_name = name.clone(); - if !exists { - constraints.push(TableConstraint::Unique { - name: constraint_name, - columns: vec![col.name.clone()], - }); + if !unique_groups.contains_key(&unique_name) { + unique_order.push(unique_name.clone()); } + + unique_groups + .entry(unique_name) + .or_default() + .push(col.name.clone()); } StrOrBoolOrArray::Bool(true) => { - let exists = constraints.iter().any(|c| { - if let TableConstraint::Unique { - name: None, - columns, - } = c - { - columns.len() == 1 && columns[0] == col.name - } else { - false - } - }); + // Use special marker for auto-generated unique constraints (without custom name) + let group_key = format!("__auto_{}", col.name); - if !exists { - constraints.push(TableConstraint::Unique { - name: None, - columns: vec![col.name.clone()], - }); + if !unique_groups.contains_key(&group_key) { + unique_order.push(group_key.clone()); } + + unique_groups + .entry(group_key) + .or_default() + .push(col.name.clone()); } StrOrBoolOrArray::Bool(false) => continue, StrOrBoolOrArray::Array(names) => { // Array format: each element is a constraint name // This column will be part of all these named constraints - for constraint_name in names { - // Check if constraint with this name already exists - if let Some(existing) = constraints.iter_mut().find(|c| { - if let TableConstraint::Unique { name: Some(n), .. } = c { - n == constraint_name - } else { - false - } - }) { - // Add this column to existing composite constraint - if let TableConstraint::Unique { columns, .. } = existing - && !columns.contains(&col.name) - { - columns.push(col.name.clone()); - } - } else { - // Create new constraint with this column - constraints.push(TableConstraint::Unique { - name: Some(constraint_name.clone()), - columns: vec![col.name.clone()], - }); + for unique_name in names { + if !unique_groups.contains_key(unique_name.as_str()) { + unique_order.push(unique_name.clone()); } + + unique_groups + .entry(unique_name.clone()) + .or_default() + .push(col.name.clone()); } } } } + } + + // Create unique constraints from grouped columns in order + for unique_name in unique_order { + let columns = unique_groups.get(&unique_name).unwrap().clone(); + + // Determine if this is an auto-generated unique (from unique: true) + // or a named unique (from unique: "name") + let constraint_name = if unique_name.starts_with("__auto_") { + // Auto-generated unique - use None so SQL generation can create the name + None + } else { + // Named unique - preserve the custom name + Some(unique_name.clone()) + }; + + // Check if this unique constraint already exists + let exists = constraints.iter().any(|c| { + if let TableConstraint::Unique { + name, + columns: cols, + } = c + { + // Match by name if both have names, otherwise match by columns + match (&constraint_name, name) { + (Some(n1), Some(n2)) => n1 == n2, + (None, None) => cols == &columns, + _ => false, + } + } else { + false + } + }); + + if !exists { + constraints.push(TableConstraint::Unique { + name: constraint_name, + columns, + }); + } + } + // Process inline foreign_key and index for each column + for col in &self.columns { // Handle inline foreign_key if let Some(ref fk_syntax) = col.foreign_key { // Convert ForeignKeySyntax to ForeignKeyDef @@ -539,6 +553,84 @@ mod tests { )); } + #[test] + fn normalize_composite_unique_from_string_name() { + // Test that multiple columns with the same unique constraint name + // are grouped into a single composite unique constraint + let mut route_col = col("join_route", ColumnType::Simple(SimpleColumnType::Text)); + route_col.unique = Some(StrOrBoolOrArray::Str("route_provider_id".into())); + + let mut provider_col = col("provider_id", ColumnType::Simple(SimpleColumnType::Text)); + provider_col.unique = Some(StrOrBoolOrArray::Str("route_provider_id".into())); + + let table = TableDef { + name: "user".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + route_col, + provider_col, + ], + constraints: vec![], + }; + + let normalized = table.normalize().unwrap(); + assert_eq!(normalized.constraints.len(), 1); + assert!(matches!( + &normalized.constraints[0], + TableConstraint::Unique { name: Some(n), columns } + if n == "route_provider_id" + && columns == &["join_route".to_string(), "provider_id".to_string()] + )); + } + + #[test] + fn normalize_unique_name_mismatch_creates_both_constraints() { + // Test coverage for line 181: When an inline unique has a name but existing doesn't (or vice versa), + // they should not match and both constraints should be created + let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text)); + email_col.unique = Some(StrOrBoolOrArray::Str("named_unique".into())); + + let table = TableDef { + name: "user".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + email_col, + ], + constraints: vec![ + // Existing unnamed unique constraint on same column + TableConstraint::Unique { + name: None, + columns: vec!["email".into()], + }, + ], + }; + + let normalized = table.normalize().unwrap(); + + // Should have 2 unique constraints: one named, one unnamed + let unique_constraints: Vec<_> = normalized + .constraints + .iter() + .filter(|c| matches!(c, TableConstraint::Unique { .. })) + .collect(); + assert_eq!( + unique_constraints.len(), + 2, + "Should keep both named and unnamed unique constraints as they don't match" + ); + + // Verify we have one named and one unnamed + let has_named = unique_constraints.iter().any( + |c| matches!(c, TableConstraint::Unique { name: Some(n), .. } if n == "named_unique"), + ); + let has_unnamed = unique_constraints + .iter() + .any(|c| matches!(c, TableConstraint::Unique { name: None, .. })); + + assert!(has_named, "Should have named unique constraint"); + assert!(has_unnamed, "Should have unnamed unique constraint"); + } + #[test] fn normalize_inline_index_bool() { let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text)); diff --git a/crates/vespertide-exporter/src/seaorm/mod.rs b/crates/vespertide-exporter/src/seaorm/mod.rs index 4fd5eb1..8f326bc 100644 --- a/crates/vespertide-exporter/src/seaorm/mod.rs +++ b/crates/vespertide-exporter/src/seaorm/mod.rs @@ -204,28 +204,30 @@ fn format_default_value(value: &str, column_type: &ColumnType) -> String { ColumnType::Complex(ComplexColumnType::Numeric { .. }) => { format!("default_value = {}", cleaned) } - // Enum type: use enum variant format - ColumnType::Complex(ComplexColumnType::Enum { name, values }) => { - let enum_name = to_pascal_case(name); - let variant = match values { + // Enum type: use the actual database value (string or number), not Rust enum variant + ColumnType::Complex(ComplexColumnType::Enum { values, .. }) => { + match values { EnumValues::String(_) => { - // String enum: cleaned is the string value, convert to PascalCase - to_pascal_case(cleaned) + // String enum: use the string value as-is with quotes + format!("default_value = \"{}\"", cleaned) } EnumValues::Integer(int_values) => { - // Integer enum: cleaned is a number, find the matching variant name + // Integer enum: can be either a number or a variant name + // Try to parse as number first if let Ok(num) = cleaned.parse::() { - int_values - .iter() - .find(|v| v.value == num) - .map(|v| to_pascal_case(&v.name)) - .unwrap_or_else(|| to_pascal_case(cleaned)) + // Already a number, use as-is + format!("default_value = {}", num) } else { - to_pascal_case(cleaned) + // It's a variant name, find the corresponding numeric value + let numeric_value = int_values + .iter() + .find(|v| v.name.eq_ignore_ascii_case(cleaned)) + .map(|v| v.value) + .unwrap_or(0); // Default to 0 if not found + format!("default_value = {}", numeric_value) } } - }; - format!("default_value = {}::{}", enum_name, variant) + } } // All other types: use quotes _ => { diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@1.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@1.snap index 4da7ac9..e208f50 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@1.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@1.snap @@ -19,7 +19,7 @@ pub enum TaskStatus { pub struct Model { #[sea_orm(primary_key)] pub id: i32, - #[sea_orm(default_value = TaskStatus::InProgress)] + #[sea_orm(default_value = 1)] pub status: TaskStatus, } diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@pending_status.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@pending_status.snap index 5dacd3b..7b54e47 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@pending_status.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__integer_enum_default_value_snapshots@pending_status.snap @@ -19,7 +19,7 @@ pub enum TaskStatus { pub struct Model { #[sea_orm(primary_key)] pub id: i32, - #[sea_orm(default_value = TaskStatus::PendingStatus)] + #[sea_orm(default_value = 0)] pub status: TaskStatus, } diff --git a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_enum_with_default.snap b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_enum_with_default.snap index f2c9e63..af610a6 100644 --- a/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_enum_with_default.snap +++ b/crates/vespertide-exporter/src/seaorm/snapshots/vespertide_exporter__seaorm__tests__render_entity_snapshots@params_enum_with_default.snap @@ -22,7 +22,7 @@ pub enum TaskStatus { pub struct Model { #[sea_orm(primary_key)] pub id: i32, - #[sea_orm(default_value = TaskStatus::Pending)] + #[sea_orm(default_value = "pending")] pub status: TaskStatus, #[sea_orm(default_value = 0)] pub priority: i32, diff --git a/crates/vespertide-naming/src/lib.rs b/crates/vespertide-naming/src/lib.rs index 548c978..7cf8b13 100644 --- a/crates/vespertide-naming/src/lib.rs +++ b/crates/vespertide-naming/src/lib.rs @@ -1,114 +1,155 @@ -//! Naming conventions and helpers for vespertide database schema management. -//! -//! This crate provides consistent naming functions for database objects like -//! indexes, constraints, and foreign keys. It has no dependencies and can be -//! used by any other vespertide crate. - -/// Generate index name from table name, columns, and optional user-provided key. -/// Always includes table name to avoid conflicts across tables. -/// Uses double underscore to separate table name from the rest. -/// Format: ix_{table}__{key} or ix_{table}__{col1}_{col2}... -pub fn build_index_name(table: &str, columns: &[String], key: Option<&str>) -> String { - match key { - Some(k) => format!("ix_{}__{}", table, k), - None => format!("ix_{}__{}", table, columns.join("_")), - } -} - -/// Generate unique constraint name from table name, columns, and optional user-provided key. -/// Always includes table name to avoid conflicts across tables. -/// Uses double underscore to separate table name from the rest. -/// Format: uq_{table}__{key} or uq_{table}__{col1}_{col2}... -pub fn build_unique_constraint_name(table: &str, columns: &[String], key: Option<&str>) -> String { - match key { - Some(k) => format!("uq_{}__{}", table, k), - None => format!("uq_{}__{}", table, columns.join("_")), - } -} - -/// Generate foreign key constraint name from table name, columns, and optional user-provided key. -/// Always includes table name to avoid conflicts across tables. -/// Uses double underscore to separate table name from the rest. -/// Format: fk_{table}__{key} or fk_{table}__{col1}_{col2}... -pub fn build_foreign_key_name(table: &str, columns: &[String], key: Option<&str>) -> String { - match key { - Some(k) => format!("fk_{}__{}", table, k), - None => format!("fk_{}__{}", table, columns.join("_")), - } -} - -/// Generate CHECK constraint name for SQLite enum column. -/// Uses double underscore to separate table name from the rest. -/// Format: chk_{table}__{column} -pub fn build_check_constraint_name(table: &str, column: &str) -> String { - format!("chk_{}__{}", table, column) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_index_name_with_key() { - assert_eq!( - build_index_name("users", &["email".into()], Some("email_idx")), - "ix_users__email_idx" - ); - } - - #[test] - fn test_build_index_name_without_key() { - assert_eq!( - build_index_name("users", &["email".into()], None), - "ix_users__email" - ); - } - - #[test] - fn test_build_index_name_multiple_columns() { - assert_eq!( - build_index_name("users", &["first_name".into(), "last_name".into()], None), - "ix_users__first_name_last_name" - ); - } - - #[test] - fn test_build_unique_constraint_name_with_key() { - assert_eq!( - build_unique_constraint_name("users", &["email".into()], Some("email_unique")), - "uq_users__email_unique" - ); - } - - #[test] - fn test_build_unique_constraint_name_without_key() { - assert_eq!( - build_unique_constraint_name("users", &["email".into()], None), - "uq_users__email" - ); - } - - #[test] - fn test_build_foreign_key_name_with_key() { - assert_eq!( - build_foreign_key_name("posts", &["user_id".into()], Some("fk_user")), - "fk_posts__fk_user" - ); - } - - #[test] - fn test_build_foreign_key_name_without_key() { - assert_eq!( - build_foreign_key_name("posts", &["user_id".into()], None), - "fk_posts__user_id" - ); - } - - #[test] - fn test_build_check_constraint_name() { - assert_eq!( - build_check_constraint_name("users", "status"), - "chk_users__status" - ); - } -} +//! Naming conventions and helpers for vespertide database schema management. +//! +//! This crate provides consistent naming functions for database objects like +//! indexes, constraints, and foreign keys. It has no dependencies and can be +//! used by any other vespertide crate. + +/// Generate index name from table name, columns, and optional user-provided key. +/// Always includes table name to avoid conflicts across tables. +/// Uses double underscore to separate table name from the rest. +/// Format: ix_{table}__{key} or ix_{table}__{col1}_{col2}... +pub fn build_index_name(table: &str, columns: &[String], key: Option<&str>) -> String { + match key { + Some(k) => format!("ix_{}__{}", table, k), + None => format!("ix_{}__{}", table, columns.join("_")), + } +} + +/// Generate unique constraint name from table name, columns, and optional user-provided key. +/// Always includes table name to avoid conflicts across tables. +/// Uses double underscore to separate table name from the rest. +/// Format: uq_{table}__{key} or uq_{table}__{col1}_{col2}... +pub fn build_unique_constraint_name(table: &str, columns: &[String], key: Option<&str>) -> String { + match key { + Some(k) => format!("uq_{}__{}", table, k), + None => format!("uq_{}__{}", table, columns.join("_")), + } +} + +/// Generate foreign key constraint name from table name, columns, and optional user-provided key. +/// Always includes table name to avoid conflicts across tables. +/// Uses double underscore to separate table name from the rest. +/// Format: fk_{table}__{key} or fk_{table}__{col1}_{col2}... +pub fn build_foreign_key_name(table: &str, columns: &[String], key: Option<&str>) -> String { + match key { + Some(k) => format!("fk_{}__{}", table, k), + None => format!("fk_{}__{}", table, columns.join("_")), + } +} + +/// Generate CHECK constraint name for SQLite enum column. +/// Uses double underscore to separate table name from the rest. +/// Format: chk_{table}__{column} +pub fn build_check_constraint_name(table: &str, column: &str) -> String { + format!("chk_{}__{}", table, column) +} + +/// Generate enum type name with table prefix to avoid conflicts. +/// Always includes table name to ensure uniqueness across tables. +/// Format: {table}_{enum_name} +/// +/// This prevents conflicts when multiple tables use the same enum name +/// (e.g., "status" or "gender") with potentially different values. +pub fn build_enum_type_name(table: &str, enum_name: &str) -> String { + format!("{}_{}", table, enum_name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_index_name_with_key() { + assert_eq!( + build_index_name("users", &["email".into()], Some("email_idx")), + "ix_users__email_idx" + ); + } + + #[test] + fn test_build_index_name_without_key() { + assert_eq!( + build_index_name("users", &["email".into()], None), + "ix_users__email" + ); + } + + #[test] + fn test_build_index_name_multiple_columns() { + assert_eq!( + build_index_name("users", &["first_name".into(), "last_name".into()], None), + "ix_users__first_name_last_name" + ); + } + + #[test] + fn test_build_unique_constraint_name_with_key() { + assert_eq!( + build_unique_constraint_name("users", &["email".into()], Some("email_unique")), + "uq_users__email_unique" + ); + } + + #[test] + fn test_build_unique_constraint_name_without_key() { + assert_eq!( + build_unique_constraint_name("users", &["email".into()], None), + "uq_users__email" + ); + } + + #[test] + fn test_build_foreign_key_name_with_key() { + assert_eq!( + build_foreign_key_name("posts", &["user_id".into()], Some("fk_user")), + "fk_posts__fk_user" + ); + } + + #[test] + fn test_build_foreign_key_name_without_key() { + assert_eq!( + build_foreign_key_name("posts", &["user_id".into()], None), + "fk_posts__user_id" + ); + } + + #[test] + fn test_build_check_constraint_name() { + assert_eq!( + build_check_constraint_name("users", "status"), + "chk_users__status" + ); + } + + #[test] + fn test_build_enum_type_name() { + assert_eq!(build_enum_type_name("users", "status"), "users_status"); + } + + #[test] + fn test_build_enum_type_name_with_existing_prefix() { + // Even if enum_name already has table prefix, we add it + // User should provide clean enum name (e.g., "status" not "users_status") + assert_eq!( + build_enum_type_name("users", "user_status"), + "users_user_status" + ); + } + + #[test] + fn test_build_enum_type_name_prevents_conflicts() { + // Different tables can have same enum name without conflict + assert_eq!(build_enum_type_name("users", "gender"), "users_gender"); + assert_eq!( + build_enum_type_name("employees", "gender"), + "employees_gender" + ); + + assert_eq!(build_enum_type_name("orders", "status"), "orders_status"); + assert_eq!( + build_enum_type_name("shipments", "status"), + "shipments_status" + ); + } +} diff --git a/crates/vespertide-query/src/sql/add_column.rs b/crates/vespertide-query/src/sql/add_column.rs index 797bb85..e06087a 100644 --- a/crates/vespertide-query/src/sql/add_column.rs +++ b/crates/vespertide-query/src/sql/add_column.rs @@ -4,7 +4,7 @@ use vespertide_core::{ColumnDef, TableDef}; use super::create_table::build_create_table_for_backend; use super::helpers::{ - build_create_enum_type_sql, build_schema_statement, build_sea_column_def, + build_create_enum_type_sql, build_schema_statement, build_sea_column_def_with_table, collect_sqlite_enum_check_clauses, }; use super::rename_table::build_rename_table; @@ -16,7 +16,7 @@ fn build_add_column_alter_for_backend( table: &str, column: &ColumnDef, ) -> TableAlterStatement { - let col_def = build_sea_column_def(backend, column); + let col_def = build_sea_column_def_with_table(backend, table, column); Table::alter() .table(Alias::new(table)) .add_column(col_def) @@ -121,11 +121,8 @@ pub fn build_add_column( let mut index_queries = Vec::new(); for constraint in &table_def.constraints { if let vespertide_core::TableConstraint::Index { name, columns } = constraint { - let index_name = vespertide_naming::build_index_name( - table, - columns, - name.as_deref(), - ); + let index_name = + vespertide_naming::build_index_name(table, columns, name.as_deref()); let mut idx_stmt = sea_query::Index::create(); idx_stmt = idx_stmt.name(&index_name).to_owned(); for col_name in columns { @@ -144,7 +141,7 @@ pub fn build_add_column( let mut stmts: Vec = Vec::new(); // If column type is an enum, create the type first (PostgreSQL only) - if let Some(create_type_sql) = build_create_enum_type_sql(&column.r#type) { + if let Some(create_type_sql) = build_create_enum_type_sql(table, &column.r#type) { stmts.push(BuiltQuery::Raw(create_type_sql)); } @@ -170,7 +167,7 @@ pub fn build_add_column( } // Set NOT NULL - let not_null_col = build_sea_column_def(backend, column); + let not_null_col = build_sea_column_def_with_table(backend, table, column); let alter_not_null = Table::alter() .table(Alias::new(table)) .modify_column(not_null_col) diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index e6e9132..983ebfd 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -268,11 +268,8 @@ pub fn build_add_constraint( Ok(queries) } else { // Build foreign key using ForeignKey::create - let fk_name = vespertide_naming::build_foreign_key_name( - table, - columns, - name.as_deref(), - ); + let fk_name = + vespertide_naming::build_foreign_key_name(table, columns, name.as_deref()); let mut fk = ForeignKey::create(); fk = fk.name(&fk_name).to_owned(); fk = fk.from_tbl(Alias::new(table)).to_owned(); diff --git a/crates/vespertide-query/src/sql/create_table.rs b/crates/vespertide-query/src/sql/create_table.rs index 38e0023..9d5fc14 100644 --- a/crates/vespertide-query/src/sql/create_table.rs +++ b/crates/vespertide-query/src/sql/create_table.rs @@ -3,7 +3,7 @@ use sea_query::{Alias, ForeignKey, Index, Table, TableCreateStatement}; use vespertide_core::{ColumnDef, ColumnType, ComplexColumnType, TableConstraint}; use super::helpers::{ - build_create_enum_type_sql, build_schema_statement, build_sea_column_def, + build_create_enum_type_sql, build_schema_statement, build_sea_column_def_with_table, collect_sqlite_enum_check_clauses, to_sea_fk_action, }; use super::types::{BuiltQuery, DatabaseBackend, RawSql}; @@ -23,7 +23,7 @@ pub(crate) fn build_create_table_for_backend( // Add columns for column in columns { - let mut col = build_sea_column_def(backend, column); + let mut col = build_sea_column_def_with_table(backend, table, column); // Check for inline primary key if column.primary_key.is_some() && !has_table_primary_key { @@ -145,7 +145,7 @@ pub fn build_create_table( for column in columns { if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &column.r#type && created_enums.insert(name.clone()) - && let Some(create_type_sql) = build_create_enum_type_sql(&column.r#type) + && let Some(create_type_sql) = build_create_enum_type_sql(table, &column.r#type) { queries.push(BuiltQuery::Raw(create_type_sql)); } diff --git a/crates/vespertide-query/src/sql/delete_column.rs b/crates/vespertide-query/src/sql/delete_column.rs index fc7fa02..230da0a 100644 --- a/crates/vespertide-query/src/sql/delete_column.rs +++ b/crates/vespertide-query/src/sql/delete_column.rs @@ -23,7 +23,7 @@ pub fn build_delete_column( // If column type is an enum, drop the type after (PostgreSQL only) // Note: Only drop if this is the last column using this enum type if let Some(col_type) = column_type - && let Some(drop_type_sql) = build_drop_enum_type_sql(col_type) + && let Some(drop_type_sql) = build_drop_enum_type_sql(table, col_type) { stmts.push(BuiltQuery::Raw(drop_type_sql)); } @@ -93,7 +93,7 @@ mod tests { assert!(alter_sql.contains("DROP COLUMN")); let drop_type_sql = result[1].build(DatabaseBackend::Postgres); - assert!(drop_type_sql.contains("DROP TYPE IF EXISTS \"status\"")); + assert!(drop_type_sql.contains("DROP TYPE IF EXISTS \"users_status\"")); // MySQL and SQLite should have empty DROP TYPE let drop_type_mysql = result[1].build(DatabaseBackend::MySql); diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 5951f76..f3900b0 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -33,8 +33,8 @@ pub fn build_query_statement( } } -/// Apply vespertide ColumnType to sea_query ColumnDef -pub fn apply_column_type(col: &mut SeaColumnDef, ty: &ColumnType) { +/// Apply vespertide ColumnType to sea_query ColumnDef with table-aware enum type naming +pub fn apply_column_type_with_table(col: &mut SeaColumnDef, ty: &ColumnType, table: &str) { match ty { ColumnType::Simple(simple) => match simple { SimpleColumnType::SmallInt => { @@ -116,8 +116,10 @@ pub fn apply_column_type(col: &mut SeaColumnDef, ty: &ColumnType) { if values.is_integer() { col.integer(); } else { + // Use table-prefixed enum type name to avoid conflicts + let type_name = build_enum_type_name(table, name); col.enumeration( - Alias::new(name), + Alias::new(&type_name), values .variant_names() .into_iter() @@ -169,10 +171,14 @@ pub fn convert_default_for_backend(default: &str, backend: &DatabaseBackend) -> } } -/// Build sea_query ColumnDef from vespertide ColumnDef for a specific backend -pub fn build_sea_column_def(backend: &DatabaseBackend, column: &ColumnDef) -> SeaColumnDef { +/// Build sea_query ColumnDef from vespertide ColumnDef for a specific backend with table-aware enum naming +pub fn build_sea_column_def_with_table( + backend: &DatabaseBackend, + table: &str, + column: &ColumnDef, +) -> SeaColumnDef { let mut col = SeaColumnDef::new(Alias::new(&column.name)); - apply_column_type(&mut col, &column.r#type); + apply_column_type_with_table(&mut col, &column.r#type, table); if !column.nullable { col.not_null(); @@ -188,7 +194,13 @@ pub fn build_sea_column_def(backend: &DatabaseBackend, column: &ColumnDef) -> Se /// Generate CREATE TYPE SQL for an enum type (PostgreSQL only) /// Returns None for non-PostgreSQL backends or non-enum types -pub fn build_create_enum_type_sql(column_type: &ColumnType) -> Option { +/// +/// The enum type name will be prefixed with the table name to avoid conflicts +/// across tables using the same enum name (e.g., "status", "gender"). +pub fn build_create_enum_type_sql( + table: &str, + column_type: &ColumnType, +) -> Option { if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = column_type { // Integer enums don't need CREATE TYPE - they use INTEGER column if values.is_integer() { @@ -197,8 +209,11 @@ pub fn build_create_enum_type_sql(column_type: &ColumnType) -> Option Option Option { +/// +/// The enum type name will be prefixed with the table name to match the CREATE TYPE. +pub fn build_drop_enum_type_sql( + table: &str, + column_type: &ColumnType, +) -> Option { if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = column_type { - // PostgreSQL: DROP TYPE IF EXISTS name - let pg_sql = format!("DROP TYPE IF EXISTS \"{}\"", name); + // Generate the same unique type name used in CREATE TYPE + let type_name = build_enum_type_name(table, name); + + // PostgreSQL: DROP TYPE IF EXISTS {table}_{name} + let pg_sql = format!("DROP TYPE IF EXISTS \"{}\"", type_name); // MySQL/SQLite: No action needed Some(super::types::RawSql::per_backend( @@ -240,7 +263,7 @@ pub fn is_enum_type(column_type: &ColumnType) -> bool { // Re-export naming functions from vespertide-naming pub use vespertide_naming::{ - build_check_constraint_name, build_foreign_key_name, build_index_name, + build_check_constraint_name, build_enum_type_name, build_foreign_key_name, build_index_name, build_unique_constraint_name, }; @@ -304,7 +327,7 @@ mod tests { fn test_column_type_conversion(#[case] ty: ColumnType) { // Just ensure no panic - test by creating a column with this type let mut col = SeaColumnDef::new(Alias::new("test")); - apply_column_type(&mut col, &ty); + apply_column_type_with_table(&mut col, &ty, "test_table"); } #[rstest] @@ -330,7 +353,7 @@ mod tests { #[case(SimpleColumnType::Xml)] fn test_all_simple_types_cover_branches(#[case] ty: SimpleColumnType) { let mut col = SeaColumnDef::new(Alias::new("t")); - apply_column_type(&mut col, &ColumnType::Simple(ty)); + apply_column_type_with_table(&mut col, &ColumnType::Simple(ty), "test_table"); } #[rstest] @@ -341,7 +364,7 @@ mod tests { #[case(ComplexColumnType::Enum { name: "status".into(), values: EnumValues::String(vec!["active".into(), "inactive".into()]) })] fn test_all_complex_types_cover_branches(#[case] ty: ComplexColumnType) { let mut col = SeaColumnDef::new(Alias::new("t")); - apply_column_type(&mut col, &ColumnType::Complex(ty)); + apply_column_type_with_table(&mut col, &ColumnType::Complex(ty), "test_table"); } #[rstest] @@ -482,7 +505,7 @@ mod tests { ]), }); let mut col = SeaColumnDef::new(Alias::new("color")); - apply_column_type(&mut col, &integer_enum); + apply_column_type_with_table(&mut col, &integer_enum, "test_table"); // Integer enums should use INTEGER type, not ENUM } @@ -503,6 +526,6 @@ mod tests { ]), }); // Integer enums should return None (no CREATE TYPE needed) - assert!(build_create_enum_type_sql(&integer_enum).is_none()); + assert!(build_create_enum_type_sql("test_table", &integer_enum).is_none()); } } diff --git a/crates/vespertide-query/src/sql/modify_column_type.rs b/crates/vespertide-query/src/sql/modify_column_type.rs index 397e7ad..b540eae 100644 --- a/crates/vespertide-query/src/sql/modify_column_type.rs +++ b/crates/vespertide-query/src/sql/modify_column_type.rs @@ -3,7 +3,7 @@ use sea_query::{Alias, ColumnDef as SeaColumnDef, Query, Table}; use vespertide_core::{ColumnType, ComplexColumnType, TableDef}; use super::create_table::build_create_table_for_backend; -use super::helpers::{apply_column_type, build_create_enum_type_sql}; +use super::helpers::{apply_column_type_with_table, build_create_enum_type_sql}; use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend}; use crate::error::QueryError; @@ -83,11 +83,8 @@ pub fn build_modify_column_type( let mut index_queries = Vec::new(); for constraint in &table_def.constraints { if let vespertide_core::TableConstraint::Index { name, columns } = constraint { - let index_name = vespertide_naming::build_index_name( - table, - columns, - name.as_deref(), - ); + let index_name = + vespertide_naming::build_index_name(table, columns, name.as_deref()); let mut idx_stmt = sea_query::Index::create(); idx_stmt = idx_stmt.name(&index_name).to_owned(); for col_name in columns { @@ -137,9 +134,11 @@ pub fn build_modify_column_type( }), ) = (old_type, new_type) { - let temp_type_name = format!("{}_new", enum_name); + // Use table-prefixed enum type names + let type_name = super::helpers::build_enum_type_name(table, enum_name); + let temp_type_name = format!("{}_new", type_name); - // 1. CREATE TYPE {enum}_new AS ENUM (new values) + // 1. CREATE TYPE {table}_{enum}_new AS ENUM (new values) let create_temp_values = new_values.to_sql_values().join(", "); queries.push(BuiltQuery::Raw(super::types::RawSql::per_backend( format!( @@ -150,7 +149,7 @@ pub fn build_modify_column_type( String::new(), ))); - // 2. ALTER TABLE ... ALTER COLUMN ... TYPE {enum}_new USING {column}::text::{enum}_new + // 2. ALTER TABLE ... ALTER COLUMN ... TYPE {table}_{enum}_new USING {column}::text::{table}_{enum}_new queries.push(BuiltQuery::Raw(super::types::RawSql::per_backend( format!( "ALTER TABLE \"{}\" ALTER COLUMN \"{}\" TYPE \"{}\" USING \"{}\"::text::\"{}\"", @@ -160,18 +159,18 @@ pub fn build_modify_column_type( String::new(), ))); - // 3. DROP TYPE {enum} + // 3. DROP TYPE {table}_{enum} queries.push(BuiltQuery::Raw(super::types::RawSql::per_backend( - format!("DROP TYPE \"{}\"", enum_name), + format!("DROP TYPE \"{}\"", type_name), String::new(), String::new(), ))); - // 4. ALTER TYPE {enum}_new RENAME TO {enum} + // 4. ALTER TYPE {table}_{enum}_new RENAME TO {table}_{enum} queries.push(BuiltQuery::Raw(super::types::RawSql::per_backend( format!( "ALTER TYPE \"{}\" RENAME TO \"{}\"", - temp_type_name, enum_name + temp_type_name, type_name ), String::new(), String::new(), @@ -196,14 +195,15 @@ pub fn build_modify_column_type( true }; - if should_create && let Some(create_type_sql) = build_create_enum_type_sql(new_type) + if should_create + && let Some(create_type_sql) = build_create_enum_type_sql(table, new_type) { queries.push(BuiltQuery::Raw(create_type_sql)); } } let mut col = SeaColumnDef::new(Alias::new(column)); - apply_column_type(&mut col, new_type); + apply_column_type_with_table(&mut col, new_type, table); let stmt = Table::alter() .table(Alias::new(table)) @@ -223,8 +223,10 @@ pub fn build_modify_column_type( }; if should_drop { + // Use table-prefixed enum type name + let old_type_name = super::helpers::build_enum_type_name(table, old_name); queries.push(BuiltQuery::Raw(super::types::RawSql::per_backend( - format!("DROP TYPE IF EXISTS \"{}\"", old_name), + format!("DROP TYPE IF EXISTS \"{}\"", old_type_name), String::new(), String::new(), ))); diff --git a/crates/vespertide-query/src/sql/remove_constraint.rs b/crates/vespertide-query/src/sql/remove_constraint.rs index 02fb374..4c487dd 100644 --- a/crates/vespertide-query/src/sql/remove_constraint.rs +++ b/crates/vespertide-query/src/sql/remove_constraint.rs @@ -345,11 +345,8 @@ pub fn build_remove_constraint( Ok(queries) } else { // Build foreign key drop using ForeignKey::drop() - let constraint_name = vespertide_naming::build_foreign_key_name( - table, - columns, - name.as_deref(), - ); + let constraint_name = + vespertide_naming::build_foreign_key_name(table, columns, name.as_deref()); let fk_drop = ForeignKey::drop() .name(&constraint_name) .table(Alias::new(table)) diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_column__tests__add_column_with_enum_type@add_column_with_enum_type_Postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_column__tests__add_column_with_enum_type@add_column_with_enum_type_Postgres.snap index 779b019..8536fd9 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_column__tests__add_column_with_enum_type@add_column_with_enum_type_Postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_column__tests__add_column_with_enum_type@add_column_with_enum_type_Postgres.snap @@ -2,5 +2,5 @@ source: crates/vespertide-query/src/sql/add_column.rs expression: sql --- -CREATE TYPE "status_type" AS ENUM ('active', 'inactive'); -ALTER TABLE "users" ADD COLUMN "status" status_type +CREATE TYPE "users_status_type" AS ENUM ('active', 'inactive'); +ALTER TABLE "users" ADD COLUMN "status" users_status_type diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__create_table__tests__create_table_with_enum_column@create_table_with_enum_column_Postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__create_table__tests__create_table_with_enum_column@create_table_with_enum_column_Postgres.snap index cdaeea8..53247fc 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__create_table__tests__create_table_with_enum_column@create_table_with_enum_column_Postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__create_table__tests__create_table_with_enum_column@create_table_with_enum_column_Postgres.snap @@ -2,5 +2,5 @@ source: crates/vespertide-query/src/sql/create_table.rs expression: sql --- -CREATE TYPE "user_status" AS ENUM ('active', 'inactive', 'pending'); -CREATE TABLE "users" ( "id" integer NOT NULL, "status" user_status NOT NULL DEFAULT 'active', PRIMARY KEY ("id") ) +CREATE TYPE "users_user_status" AS ENUM ('active', 'inactive', 'pending'); +CREATE TABLE "users" ( "id" integer NOT NULL, "status" users_user_status NOT NULL DEFAULT 'active', PRIMARY KEY ("id") ) diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_postgres.snap index 1d15b02..523a55b 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_postgres.snap @@ -2,6 +2,6 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TYPE "new_status" AS ENUM ('active', 'inactive'); -ALTER TABLE "users" ALTER COLUMN "status" TYPE new_status; -DROP TYPE IF EXISTS "old_status" +CREATE TYPE "users_new_status" AS ENUM ('active', 'inactive'); +ALTER TABLE "users" ALTER COLUMN "status" TYPE users_new_status; +DROP TYPE IF EXISTS "users_old_status" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_postgres.snap index c13f674..48781cc 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_postgres.snap @@ -2,4 +2,4 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -ALTER TABLE "users" ALTER COLUMN "status" TYPE status +ALTER TABLE "users" ALTER COLUMN "status" TYPE users_status diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_to_text_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_to_text_postgres.snap index cc1af40..56ff680 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_to_text_postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_to_text_postgres.snap @@ -3,4 +3,4 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- ALTER TABLE "users" ALTER COLUMN "status" TYPE text; -DROP TYPE IF EXISTS "user_status" +DROP TYPE IF EXISTS "users_user_status" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_postgres.snap index cc7060c..85790b6 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_postgres.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TYPE "status_new" AS ENUM ('active', 'inactive', 'pending'); -ALTER TABLE "users" ALTER COLUMN "status" TYPE "status_new" USING "status"::text::"status_new"; -DROP TYPE "status"; -ALTER TYPE "status_new" RENAME TO "status" +CREATE TYPE "users_status_new" AS ENUM ('active', 'inactive', 'pending'); +ALTER TABLE "users" ALTER COLUMN "status" TYPE "users_status_new" USING "status"::text::"users_status_new"; +DROP TYPE "users_status"; +ALTER TYPE "users_status_new" RENAME TO "users_status" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_postgres.snap index 1cbd184..67e4846 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_postgres.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_postgres.snap @@ -2,5 +2,5 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TYPE "user_status" AS ENUM ('active', 'inactive'); -ALTER TABLE "users" ALTER COLUMN "status" TYPE user_status +CREATE TYPE "users_user_status" AS ENUM ('active', 'inactive'); +ALTER TABLE "users" ALTER COLUMN "status" TYPE users_user_status diff --git a/crates/vespertide-query/tests/composite_unique_test.rs b/crates/vespertide-query/tests/composite_unique_test.rs new file mode 100644 index 0000000..326f8b6 --- /dev/null +++ b/crates/vespertide-query/tests/composite_unique_test.rs @@ -0,0 +1,100 @@ +use vespertide_core::{ + ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType, + schema::StrOrBoolOrArray, +}; +use vespertide_query::{DatabaseBackend, build_plan_queries}; + +#[test] +fn test_composite_unique_constraint_generates_single_index() { + // Test that multiple columns with the same unique constraint name + // generate a single composite unique index, not separate indexes per column + let plan = MigrationPlan { + version: 1, + comment: Some("Test composite unique".into()), + created_at: None, + actions: vec![MigrationAction::CreateTable { + table: "user".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: "join_route".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: Some(StrOrBoolOrArray::Str("route_provider_id".into())), + index: None, + foreign_key: None, + }, + ColumnDef { + name: "provider_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: Some(StrOrBoolOrArray::Str("route_provider_id".into())), + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }], + }; + + let queries = build_plan_queries(&plan, &[]).unwrap(); + + let postgres_sql = &queries[0].postgres; + println!("\n=== PostgreSQL SQL ==="); + for (i, q) in postgres_sql.iter().enumerate() { + let sql = q.build(DatabaseBackend::Postgres); + println!("{}: {}", i + 1, sql); + } + + // Should have 2 queries: CREATE TABLE and CREATE UNIQUE INDEX + assert_eq!( + postgres_sql.len(), + 2, + "Should have CREATE TABLE and one CREATE UNIQUE INDEX" + ); + + let create_table_sql = postgres_sql[0].build(DatabaseBackend::Postgres); + assert!( + create_table_sql.contains("CREATE TABLE \"user\""), + "Should create user table" + ); + + let create_unique_sql = postgres_sql[1].build(DatabaseBackend::Postgres); + println!("\nGenerated unique index SQL: {}", create_unique_sql); + + // Should create a single composite unique index, not two separate ones + assert!( + create_unique_sql.contains("CREATE UNIQUE INDEX"), + "Should create unique index" + ); + assert!( + create_unique_sql.contains("\"uq_user__route_provider_id\""), + "Should use the named constraint. Got: {}", + create_unique_sql + ); + assert!( + create_unique_sql.contains("(\"join_route\", \"provider_id\")"), + "Should include both columns in composite index. Got: {}", + create_unique_sql + ); + + println!("\n✅ Composite unique constraint correctly generates a single index!"); +} diff --git a/crates/vespertide-query/tests/enum_migration_test.rs b/crates/vespertide-query/tests/enum_migration_test.rs index 77487db..407ca7d 100644 --- a/crates/vespertide-query/tests/enum_migration_test.rs +++ b/crates/vespertide-query/tests/enum_migration_test.rs @@ -82,10 +82,11 @@ fn test_enum_value_change_generates_correct_sql() { let sql2 = postgres_queries[2].build(DatabaseBackend::Postgres); let sql3 = postgres_queries[3].build(DatabaseBackend::Postgres); - // 1. CREATE TYPE status_new + // 1. CREATE TYPE user_status_new (table-prefixed) assert!( - sql0.contains("CREATE TYPE \"status_new\""), - "Step 1 should create temp type" + sql0.contains("CREATE TYPE \"user_status_new\""), + "Step 1 should create temp type with table prefix. Got: {}", + sql0 ); assert!( sql0.contains("'active', 'inactive', 'pending'"), @@ -98,23 +99,27 @@ fn test_enum_value_change_generates_correct_sql() { "Step 2 should alter table" ); assert!( - sql1.contains("ALTER COLUMN \"status\" TYPE \"status_new\""), - "Should change column type to temp" + sql1.contains("ALTER COLUMN \"status\" TYPE \"user_status_new\""), + "Should change column type to temp with table prefix. Got: {}", + sql1 ); assert!( - sql1.contains("USING \"status\"::text::\"status_new\""), - "Should use USING clause" + sql1.contains("USING \"status\"::text::\"user_status_new\""), + "Should use USING clause with table prefix. Got: {}", + sql1 ); - // 3. DROP TYPE status + // 3. DROP TYPE user_status (table-prefixed) assert!( - sql2.contains("DROP TYPE \"status\""), - "Step 3 should drop old type" + sql2.contains("DROP TYPE \"user_status\""), + "Step 3 should drop old type with table prefix. Got: {}", + sql2 ); - // 4. RENAME TYPE + // 4. RENAME TYPE user_status_new to user_status (table-prefixed) assert!( - sql3.contains("ALTER TYPE \"status_new\" RENAME TO \"status\""), - "Step 4 should rename temp type back" + sql3.contains("ALTER TYPE \"user_status_new\" RENAME TO \"user_status\""), + "Step 4 should rename temp type back with table prefix. Got: {}", + sql3 ); } diff --git a/crates/vespertide-query/tests/table_prefixed_enum_test.rs b/crates/vespertide-query/tests/table_prefixed_enum_test.rs new file mode 100644 index 0000000..6d40d7a --- /dev/null +++ b/crates/vespertide-query/tests/table_prefixed_enum_test.rs @@ -0,0 +1,143 @@ +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 { + 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 { + 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(); + + // 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 + ); + + // 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 + ); + + 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✅ Table-prefixed enum names successfully prevent naming conflicts!"); +} diff --git a/examples/app/migrations/0001_init.json b/examples/app/migrations/0001_init.json index aa1c84d..f44b36f 100644 --- a/examples/app/migrations/0001_init.json +++ b/examples/app/migrations/0001_init.json @@ -257,14 +257,12 @@ { "columns": [ { - "foreign_key": { - "on_delete": "cascade", - "on_update": null, - "ref_columns": [ - "id" - ], - "ref_table": "article" - }, + "name": "media_id", + "nullable": false, + "primary_key": true, + "type": "uuid" + }, + { "name": "article_id", "nullable": false, "primary_key": true, @@ -311,12 +309,27 @@ "type": "timestamptz" } ], - "constraints": [], + "constraints": [ + { + "columns": [ + "media_id", + "article_id" + ], + "on_delete": "cascade", + "on_update": null, + "ref_columns": [ + "media_id", + "id" + ], + "ref_table": "article", + "type": "foreign_key" + } + ], "table": "article_user", "type": "create_table" } ], "comment": "Init", - "created_at": "2025-12-19T04:26:58Z", + "created_at": "2025-12-19T08:38:59Z", "version": 1 } \ No newline at end of file diff --git a/examples/app/models/article.json b/examples/app/models/article.json index 5b73314..ff1f113 100644 --- a/examples/app/models/article.json +++ b/examples/app/models/article.json @@ -64,5 +64,6 @@ "nullable": true } ], - "constraints": [] + "constraints": [], + "indexes": [] } diff --git a/examples/app/models/article_user.json b/examples/app/models/article_user.json index 1410956..8caf0a9 100644 --- a/examples/app/models/article_user.json +++ b/examples/app/models/article_user.json @@ -2,16 +2,17 @@ "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", "name": "article_user", "columns": [ + { + "name": "media_id", + "type": "uuid", + "nullable": false, + "primary_key": true + }, { "name": "article_id", "type": "big_int", "nullable": false, - "primary_key": true, - "foreign_key": { - "ref_table": "article", - "ref_columns": ["id"], - "on_delete": "cascade" - } + "primary_key": true }, { "name": "user_id", @@ -44,5 +45,14 @@ "default": "now()" } ], - "constraints": [] + "constraints": [ + { + "type": "foreign_key", + "columns": ["media_id", "article_id"], + "ref_table": "article", + "ref_columns": ["media_id", "id"], + "on_delete": "cascade" + } + ], + "indexes": [] } diff --git a/examples/app/models/media.json b/examples/app/models/media.json index 4f0c4e2..43552b6 100644 --- a/examples/app/models/media.json +++ b/examples/app/models/media.json @@ -47,5 +47,6 @@ "nullable": true } ], - "constraints": [] + "constraints": [], + "indexes": [] } diff --git a/examples/app/models/user.json b/examples/app/models/user.json index ba49947..7221fb1 100644 --- a/examples/app/models/user.json +++ b/examples/app/models/user.json @@ -43,5 +43,6 @@ "nullable": true } ], - "constraints": [] + "constraints": [], + "indexes": [] } diff --git a/examples/app/models/user_media_role.json b/examples/app/models/user_media_role.json index 9b1253a..eeeffed 100644 --- a/examples/app/models/user_media_role.json +++ b/examples/app/models/user_media_role.json @@ -39,5 +39,6 @@ "default": "now()" } ], - "constraints": [] + "constraints": [], + "indexes": [] } diff --git a/examples/app/src/models/article.rs b/examples/app/src/models/article.rs index e632bf1..9338080 100644 --- a/examples/app/src/models/article.rs +++ b/examples/app/src/models/article.rs @@ -43,6 +43,6 @@ pub struct Model { // Index definitions (SeaORM uses Statement builders externally) -// idx_article_status on [status] unique=false -// idx_article_published_at on [published_at] unique=false +// (unnamed) on [status] +// (unnamed) on [published_at] impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/article_user.rs b/examples/app/src/models/article_user.rs index 173b3c7..904b830 100644 --- a/examples/app/src/models/article_user.rs +++ b/examples/app/src/models/article_user.rs @@ -32,5 +32,5 @@ pub struct Model { // Index definitions (SeaORM uses Statement builders externally) -// idx_article_user_user_id on [user_id] unique=false +// (unnamed) on [user_id] impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/media.rs b/examples/app/src/models/media.rs index 11b7141..72087a6 100644 --- a/examples/app/src/models/media.rs +++ b/examples/app/src/models/media.rs @@ -27,5 +27,5 @@ pub struct Model { // Index definitions (SeaORM uses Statement builders externally) -// idx_media_owner_id on [owner_id] unique=false +// (unnamed) on [owner_id] impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user.rs b/examples/app/src/models/user.rs index 1ed1988..b4e8caa 100644 --- a/examples/app/src/models/user.rs +++ b/examples/app/src/models/user.rs @@ -29,5 +29,5 @@ pub struct Model { // Index definitions (SeaORM uses Statement builders externally) -// idx_user_email on [email] unique=false +// (unnamed) on [email] impl ActiveModelBehavior for ActiveModel {} diff --git a/examples/app/src/models/user_media_role.rs b/examples/app/src/models/user_media_role.rs index c4bdacd..f5dd331 100644 --- a/examples/app/src/models/user_media_role.rs +++ b/examples/app/src/models/user_media_role.rs @@ -32,7 +32,7 @@ pub struct Model { // Index definitions (SeaORM uses Statement builders externally) -// idx_user_media_role_user_id on [user_id] unique=false -// idx_user_media_role_media_id on [media_id] unique=false -// idx_user_media_role_role on [role] unique=false +// (unnamed) on [user_id] +// (unnamed) on [media_id] +// (unnamed) on [role] impl ActiveModelBehavior for ActiveModel {}