From ffa14e570ed9f340e5fe1a9faff015ad46bd9dbe Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 14:32:41 +0900 Subject: [PATCH 01/17] Fix sqlite mig issue --- Cargo.lock | 20 +-- crates/vespertide-planner/src/apply.rs | 163 +++++++++++++++++- crates/vespertide-query/src/builder.rs | 102 +++++++++++ ..._with_inline_index@inline_index_mysql.snap | 5 + ...th_inline_index@inline_index_postgres.snap | 5 + ...with_inline_index@inline_index_sqlite.snap | 6 + ...ith_inline_unique@inline_unique_mysql.snap | 5 + ..._inline_unique@inline_unique_postgres.snap | 5 + ...th_inline_unique@inline_unique_sqlite.snap | 6 + examples/app/src/main.rs | 4 +- 10 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_sqlite.snap diff --git a/Cargo.lock b/Cargo.lock index 47f08d8..70ea873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2972,7 +2972,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespertide" -version = "0.1.38" +version = "0.1.39" dependencies = [ "vespertide-core", "vespertide-macro", @@ -2980,7 +2980,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.38" +version = "0.1.39" dependencies = [ "anyhow", "assert_cmd", @@ -3005,7 +3005,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.38" +version = "0.1.39" dependencies = [ "clap", "schemars", @@ -3015,7 +3015,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.38" +version = "0.1.39" dependencies = [ "rstest", "schemars", @@ -3027,7 +3027,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.38" +version = "0.1.39" dependencies = [ "insta", "rstest", @@ -3038,7 +3038,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.38" +version = "0.1.39" dependencies = [ "anyhow", "rstest", @@ -3053,7 +3053,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.38" +version = "0.1.39" dependencies = [ "proc-macro2", "quote", @@ -3070,11 +3070,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.38" +version = "0.1.39" [[package]] name = "vespertide-planner" -version = "0.1.38" +version = "0.1.39" dependencies = [ "insta", "rstest", @@ -3085,7 +3085,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.38" +version = "0.1.39" dependencies = [ "insta", "rstest", diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index cde9241..de3d621 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -16,12 +16,22 @@ pub fn apply_action( if schema.iter().any(|t| t.name == *table) { return Err(PlannerError::TableExists(table.clone())); } - schema.push(TableDef { + let table_def = TableDef { name: table.clone(), description: None, columns: columns.clone(), constraints: constraints.clone(), - }); + }; + // Normalize to promote inline constraints (unique, index, foreign_key, primary_key) + // to table-level TableConstraint entries. This is critical for SQLite which needs + // to know about constraints when dropping columns. + let normalized = table_def.normalize().map_err(|e| { + PlannerError::TableValidation(format!( + "Failed to normalize table '{}': {}", + table, e + )) + })?; + schema.push(normalized); Ok(()) } MigrationAction::DeleteTable { table } => { @@ -49,6 +59,15 @@ pub fn apply_action( )) } else { tbl.columns.push((**column).clone()); + // Re-normalize to promote any inline constraints on the new column + // to table-level TableConstraint entries. + let normalized = tbl.clone().normalize().map_err(|e| { + PlannerError::TableValidation(format!( + "Failed to normalize table '{}' after adding column '{}': {}", + table, column.name, e + )) + })?; + *tbl = normalized; Ok(()) } } @@ -1142,6 +1161,146 @@ mod tests { assert!(schema[0].columns[0].index.is_none()); } + // Tests for CreateTable normalizing inline constraints + #[test] + fn create_table_normalizes_inline_unique() { + let mut col_with_unique = col("email", ColumnType::Simple(SimpleColumnType::Text)); + col_with_unique.unique = Some(vespertide_core::StrOrBoolOrArray::Bool(true)); + + let mut schema = vec![]; + apply_action( + &mut schema, + &MigrationAction::CreateTable { + table: "users".into(), + columns: vec![col_with_unique], + constraints: vec![], + }, + ) + .unwrap(); + + // Inline unique: true should be normalized to a TableConstraint::Unique + assert!( + schema[0].constraints.iter().any( + |c| matches!(c, TableConstraint::Unique { columns, .. } if columns == &["email"]) + ), + "Expected a Unique constraint on 'email', got: {:?}", + schema[0].constraints + ); + } + + #[test] + fn create_table_normalizes_inline_index() { + let mut col_with_index = col("email", ColumnType::Simple(SimpleColumnType::Text)); + col_with_index.index = Some(vespertide_core::StrOrBoolOrArray::Bool(true)); + + let mut schema = vec![]; + apply_action( + &mut schema, + &MigrationAction::CreateTable { + table: "users".into(), + columns: vec![col_with_index], + constraints: vec![], + }, + ) + .unwrap(); + + // Inline index: true should be normalized to a TableConstraint::Index + assert!( + schema[0].constraints.iter().any( + |c| matches!(c, TableConstraint::Index { columns, .. } if columns == &["email"]) + ), + "Expected an Index constraint on 'email', got: {:?}", + schema[0].constraints + ); + } + + #[test] + fn create_table_normalizes_inline_primary_key() { + let mut col_with_pk = col("id", ColumnType::Simple(SimpleColumnType::Integer)); + col_with_pk.primary_key = + Some(vespertide_core::schema::primary_key::PrimaryKeySyntax::Bool(true)); + + let mut schema = vec![]; + apply_action( + &mut schema, + &MigrationAction::CreateTable { + table: "users".into(), + columns: vec![col_with_pk], + constraints: vec![], + }, + ) + .unwrap(); + + assert!( + schema[0].constraints.iter().any( + |c| matches!(c, TableConstraint::PrimaryKey { columns, .. } if columns == &["id"]) + ), + "Expected a PrimaryKey constraint on 'id', got: {:?}", + schema[0].constraints + ); + } + + // Tests for AddColumn normalizing inline constraints + #[test] + fn add_column_normalizes_inline_unique() { + let mut schema = vec![table( + "users", + vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + vec![], + )]; + + let mut col_with_unique = col("email", ColumnType::Simple(SimpleColumnType::Text)); + col_with_unique.unique = Some(vespertide_core::StrOrBoolOrArray::Bool(true)); + + apply_action( + &mut schema, + &MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(col_with_unique), + fill_with: None, + }, + ) + .unwrap(); + + assert!( + schema[0].constraints.iter().any( + |c| matches!(c, TableConstraint::Unique { columns, .. } if columns == &["email"]) + ), + "Expected a Unique constraint on 'email' after AddColumn, got: {:?}", + schema[0].constraints + ); + } + + #[test] + fn add_column_normalizes_inline_index() { + let mut schema = vec![table( + "users", + vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + vec![], + )]; + + let mut col_with_index = col("email", ColumnType::Simple(SimpleColumnType::Text)); + col_with_index.index = Some(vespertide_core::StrOrBoolOrArray::Bool(true)); + + apply_action( + &mut schema, + &MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(col_with_index), + fill_with: None, + }, + ) + .unwrap(); + + assert!( + schema[0].constraints.iter().any( + |c| matches!(c, TableConstraint::Index { columns, .. } if columns == &["email"]) + ), + "Expected an Index constraint on 'email' after AddColumn, got: {:?}", + schema[0].constraints + ); + } + // Tests for ModifyColumnNullable #[test] fn apply_modify_column_nullable_success() { diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 0c6cecc..4b364be 100644 --- a/crates/vespertide-query/src/builder.rs +++ b/crates/vespertide-query/src/builder.rs @@ -48,6 +48,7 @@ pub fn build_plan_queries( mod tests { use super::*; use crate::sql::DatabaseBackend; + use insta::{assert_snapshot, with_settings}; use rstest::rstest; use vespertide_core::{ ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType, @@ -117,6 +118,107 @@ mod tests { ); } + fn build_sql_snapshot(result: &[BuiltQuery], backend: DatabaseBackend) -> String { + result + .iter() + .map(|q| q.build(backend)) + .collect::>() + .join(";\n") + } + + /// Regression test: SQLite must emit DROP INDEX before DROP COLUMN when + /// the column was created with inline `unique: true` (no explicit table constraint). + /// Previously, apply_action didn't normalize inline constraints, so the evolving + /// schema had empty constraints and SQLite's DROP COLUMN failed. + #[rstest] + #[case::postgres("postgres", DatabaseBackend::Postgres)] + #[case::mysql("mysql", DatabaseBackend::MySql)] + #[case::sqlite("sqlite", DatabaseBackend::Sqlite)] + fn test_delete_column_after_create_table_with_inline_unique( + #[case] title: &str, + #[case] backend: DatabaseBackend, + ) { + let mut col_with_unique = col("gift_code", ColumnType::Simple(SimpleColumnType::Text)); + col_with_unique.unique = Some(vespertide_core::StrOrBoolOrArray::Bool(true)); + + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::CreateTable { + table: "gift".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col_with_unique, + ], + constraints: vec![], // No explicit constraints - only inline unique: true + }, + MigrationAction::DeleteColumn { + table: "gift".into(), + column: "gift_code".into(), + }, + ], + }; + + let result = build_plan_queries(&plan, &[]).unwrap(); + let queries = match backend { + DatabaseBackend::Postgres => &result[1].postgres, + DatabaseBackend::MySql => &result[1].mysql, + DatabaseBackend::Sqlite => &result[1].sqlite, + }; + let sql = build_sql_snapshot(queries, backend); + + with_settings!({ snapshot_suffix => format!("inline_unique_{}", title) }, { + assert_snapshot!(sql); + }); + } + + /// Same regression test for inline `index: true`. + #[rstest] + #[case::postgres("postgres", DatabaseBackend::Postgres)] + #[case::mysql("mysql", DatabaseBackend::MySql)] + #[case::sqlite("sqlite", DatabaseBackend::Sqlite)] + fn test_delete_column_after_create_table_with_inline_index( + #[case] title: &str, + #[case] backend: DatabaseBackend, + ) { + let mut col_with_index = col("email", ColumnType::Simple(SimpleColumnType::Text)); + col_with_index.index = Some(vespertide_core::StrOrBoolOrArray::Bool(true)); + + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col_with_index, + ], + constraints: vec![], + }, + MigrationAction::DeleteColumn { + table: "users".into(), + column: "email".into(), + }, + ], + }; + + let result = build_plan_queries(&plan, &[]).unwrap(); + let queries = match backend { + DatabaseBackend::Postgres => &result[1].postgres, + DatabaseBackend::MySql => &result[1].mysql, + DatabaseBackend::Sqlite => &result[1].sqlite, + }; + let sql = build_sql_snapshot(queries, backend); + + with_settings!({ snapshot_suffix => format!("inline_index_{}", title) }, { + assert_snapshot!(sql); + }); + } + #[test] fn test_build_plan_queries_sql_content() { let plan = MigrationPlan { diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_mysql.snap new file mode 100644 index 0000000..f8bd7b2 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_mysql.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +ALTER TABLE `users` DROP COLUMN `email` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_postgres.snap new file mode 100644 index 0000000..cc1cbd0 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_postgres.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +ALTER TABLE "users" DROP COLUMN "email" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_sqlite.snap new file mode 100644 index 0000000..80f0e89 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_index@inline_index_sqlite.snap @@ -0,0 +1,6 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +DROP INDEX "ix_users__email"; +ALTER TABLE "users" DROP COLUMN "email" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_mysql.snap new file mode 100644 index 0000000..99bfc8c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_mysql.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +ALTER TABLE `gift` DROP COLUMN `gift_code` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_postgres.snap new file mode 100644 index 0000000..83903ad --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_postgres.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +ALTER TABLE "gift" DROP COLUMN "gift_code" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_sqlite.snap new file mode 100644 index 0000000..3940cb5 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__delete_column_after_create_table_with_inline_unique@inline_unique_sqlite.snap @@ -0,0 +1,6 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +DROP INDEX "uq_gift__gift_code"; +ALTER TABLE "gift" DROP COLUMN "gift_code" diff --git a/examples/app/src/main.rs b/examples/app/src/main.rs index d222edb..bc9619b 100644 --- a/examples/app/src/main.rs +++ b/examples/app/src/main.rs @@ -6,9 +6,9 @@ use std::time::Duration; async fn main() -> Result<()> { println!("Hello, world!"); - let mut opt = ConnectOptions::new("postgres://postgres:password@localhost:5432/postgres"); + // let mut opt = ConnectOptions::new("postgres://postgres:password@localhost:5432/postgres"); // Configure SQLite connection - // let mut opt = ConnectOptions::new("sqlite://./local.db"); + let mut opt = ConnectOptions::new("sqlite://./local.db"); opt.max_connections(100) .min_connections(5) .connect_timeout(Duration::from_secs(8)) From 5c81f21d7c82ad35f676221315049247a919ed67 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 17:07:08 +0900 Subject: [PATCH 02/17] Fix temp_table issue --- .gitignore | 1 + crates/vespertide-planner/src/apply.rs | 6 +- crates/vespertide-planner/src/error.rs | 4 + crates/vespertide-planner/src/validate.rs | 96 ++++++++ crates/vespertide-query/src/sql/add_column.rs | 48 +--- .../src/sql/add_constraint.rs | 207 ++++++------------ .../vespertide-query/src/sql/create_table.rs | 20 +- .../vespertide-query/src/sql/delete_column.rs | 122 ++++++++--- crates/vespertide-query/src/sql/helpers.rs | 131 ++++++++++- .../src/sql/modify_column_default.rs | 36 ++- .../src/sql/modify_column_nullable.rs | 36 ++- .../src/sql/modify_column_type.rs | 28 +-- .../src/sql/remove_constraint.rs | 126 ++--------- ...mn_type_with_unique_constraint_sqlite.snap | 3 +- ...y_enum_types_enum_name_changed_sqlite.snap | 2 +- ...fy_enum_types_enum_same_values_sqlite.snap | 2 +- ...enum_types_enum_values_changed_sqlite.snap | 2 +- ...modify_enum_types_text_to_enum_sqlite.snap | 2 +- ...fault_modify_enum_with_default_sqlite.snap | 2 +- ...e_check_with_other_constraints_Sqlite.snap | 1 + ...e_check_with_unique_constraint_Sqlite.snap | 1 + ...ign_key_with_other_constraints_Sqlite.snap | 3 +- ...ign_key_with_unique_constraint_Sqlite.snap | 1 + ...ary_key_with_unique_constraint_Sqlite.snap | 1 + ..._unique_with_other_constraints_Sqlite.snap | 2 +- ...e_with_other_unique_constraint_Sqlite.snap | 1 + 26 files changed, 488 insertions(+), 396 deletions(-) diff --git a/.gitignore b/.gitignore index f4a9ba2..55d1a75 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ local.db settings.local.json coverage lcov.info +.sisyphus diff --git a/crates/vespertide-planner/src/apply.rs b/crates/vespertide-planner/src/apply.rs index de3d621..8719e18 100644 --- a/crates/vespertide-planner/src/apply.rs +++ b/crates/vespertide-planner/src/apply.rs @@ -186,7 +186,11 @@ pub fn apply_action( .iter_mut() .find(|t| t.name == *table) .ok_or_else(|| PlannerError::TableNotFound(table.clone()))?; - tbl.constraints.push(constraint.clone()); + // Skip if an equivalent constraint already exists (e.g. inline index + // was already promoted to table-level by normalize() during AddColumn) + if !tbl.constraints.contains(constraint) { + tbl.constraints.push(constraint.clone()); + } Ok(()) } MigrationAction::RemoveConstraint { table, constraint } => { diff --git a/crates/vespertide-planner/src/error.rs b/crates/vespertide-planner/src/error.rs index 337d08b..3146627 100644 --- a/crates/vespertide-planner/src/error.rs +++ b/crates/vespertide-planner/src/error.rs @@ -36,6 +36,10 @@ pub enum PlannerError { DuplicateEnumValue(String, String, String, i32), #[error("{0}")] InvalidEnumDefault(#[from] Box), + #[error( + "auto_increment on non-integer column: {0}.{1} (type {2} does not support auto_increment)" + )] + InvalidAutoIncrement(String, String, String), } #[derive(Debug, Error)] diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index 0e0d35a..e676f9b 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -61,6 +61,44 @@ fn validate_table( return Err(PlannerError::MissingPrimaryKey(table.name.clone())); } + // Validate auto_increment columns have integer types + for constraint in &table.constraints { + if let TableConstraint::PrimaryKey { + auto_increment: true, + columns, + } = constraint + { + for col_name in columns { + if let Some(column) = table.columns.iter().find(|c| c.name == *col_name) + && !column.r#type.supports_auto_increment() { + return Err(PlannerError::InvalidAutoIncrement( + table.name.clone(), + col_name.clone(), + format!("{:?}", column.r#type), + )); + } + } + } + } + + // Validate auto_increment on inline primary_key definitions + use vespertide_core::schema::primary_key::PrimaryKeySyntax; + for column in &table.columns { + if let Some(pk_syntax) = &column.primary_key { + let has_auto_increment = match pk_syntax { + PrimaryKeySyntax::Bool(_) => false, + PrimaryKeySyntax::Object(pk_def) => pk_def.auto_increment, + }; + if has_auto_increment && !column.r#type.supports_auto_increment() { + return Err(PlannerError::InvalidAutoIncrement( + table.name.clone(), + column.name.clone(), + format!("{:?}", column.r#type), + )); + } + } + } + // Validate columns (enum types) for column in &table.columns { validate_column(column, &table.name)?; @@ -463,6 +501,7 @@ pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec { mod tests { use super::*; use rstest::rstest; + use vespertide_core::schema::primary_key::{PrimaryKeyDef, PrimaryKeySyntax}; use vespertide_core::{ ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnType, TableConstraint, @@ -1826,4 +1865,61 @@ mod tests { let missing = find_missing_fill_with(&plan); assert!(missing.is_empty()); } + + #[test] + fn validate_auto_increment_on_text_column_fails() { + let table_def = table( + "users", + vec![col("id", ColumnType::Simple(SimpleColumnType::Text))], + vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + ); + + let result = validate_table(&table_def, &std::collections::HashMap::new()); + assert!(result.is_err()); + match result { + Err(PlannerError::InvalidAutoIncrement(table_name, col_name, _)) => { + assert_eq!(table_name, "users"); + assert_eq!(col_name, "id"); + } + _ => panic!("Expected InvalidAutoIncrement error"), + } + } + + #[test] + fn validate_auto_increment_on_integer_column_succeeds() { + let table_def = table( + "users", + vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + vec![TableConstraint::PrimaryKey { + auto_increment: true, + columns: vec!["id".into()], + }], + ); + + let result = validate_table(&table_def, &std::collections::HashMap::new()); + assert!(result.is_ok()); + } + + #[test] + fn validate_inline_auto_increment_on_text_column_fails() { + let mut col_def = col("id", ColumnType::Simple(SimpleColumnType::Text)); + col_def.primary_key = Some(PrimaryKeySyntax::Object(PrimaryKeyDef { + auto_increment: true, + })); + + let table_def = table("users", vec![col_def], vec![]); + + let result = validate_table(&table_def, &std::collections::HashMap::new()); + assert!(result.is_err()); + match result { + Err(PlannerError::InvalidAutoIncrement(table_name, col_name, _)) => { + assert_eq!(table_name, "users"); + assert_eq!(col_name, "id"); + } + _ => panic!("Expected InvalidAutoIncrement error"), + } + } } diff --git a/crates/vespertide-query/src/sql/add_column.rs b/crates/vespertide-query/src/sql/add_column.rs index 9aa7be9..a8b1753 100644 --- a/crates/vespertide-query/src/sql/add_column.rs +++ b/crates/vespertide-query/src/sql/add_column.rs @@ -2,13 +2,12 @@ use sea_query::{Alias, Expr, Query, Table, TableAlterStatement}; 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_with_table, - collect_sqlite_enum_check_clauses, normalize_enum_default, normalize_fill_with, + build_create_enum_type_sql, build_sea_column_def_with_table, build_sqlite_temp_table_create, + normalize_enum_default, normalize_fill_with, recreate_indexes_after_rebuild, }; use super::rename_table::build_rename_table; -use super::types::{BuiltQuery, DatabaseBackend, RawSql}; +use super::types::{BuiltQuery, DatabaseBackend}; use crate::error::QueryError; fn build_add_column_alter_for_backend( @@ -50,32 +49,16 @@ pub fn build_add_column( new_columns.push(column.clone()); let temp_table = format!("{}_temp", table); - let create_temp = build_create_table_for_backend( + + // 1. Create temporary table with all CHECK constraints (enum + explicit) + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &new_columns, &table_def.constraints, ); - // For SQLite, add CHECK constraints for enum columns - // Use original table name for constraint naming (table will be renamed back) - let enum_check_clauses = collect_sqlite_enum_check_clauses(table, &new_columns); - let create_query = if !enum_check_clauses.is_empty() { - let base_sql = build_schema_statement(&create_temp, *backend); - let mut modified_sql = base_sql; - if let Some(pos) = modified_sql.rfind(')') { - let check_sql = enum_check_clauses.join(", "); - modified_sql.insert_str(pos, &format!(", {}", check_sql)); - } - BuiltQuery::Raw(RawSql::per_backend( - modified_sql.clone(), - modified_sql.clone(), - modified_sql, - )) - } else { - BuiltQuery::CreateTable(Box::new(create_temp)) - }; - // Copy existing data, filling new column let mut select_query = Query::select(); for col in &table_def.columns { @@ -112,21 +95,8 @@ pub fn build_add_column( BuiltQuery::DropTable(Box::new(Table::drop().table(Alias::new(table)).to_owned())); let rename_query = build_rename_table(&temp_table, table); - // Recreate indexes from Index constraints - 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 mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in columns { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut stmts = vec![create_query, insert_query, drop_query, rename_query]; stmts.extend(index_queries); diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index 84dddb5..307bd44 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -2,49 +2,12 @@ use sea_query::{Alias, ForeignKey, Index, Query, Table}; use vespertide_core::{TableConstraint, TableDef}; -use super::create_table::build_create_table_for_backend; -use super::helpers::{build_schema_statement, to_sea_fk_action}; +use super::helpers::{ + build_sqlite_temp_table_create, recreate_indexes_after_rebuild, to_sea_fk_action, +}; use super::rename_table::build_rename_table; -use super::types::{BuiltQuery, DatabaseBackend}; +use super::types::{BuiltQuery, DatabaseBackend, RawSql}; use crate::error::QueryError; -use crate::sql::RawSql; - -/// Extract CHECK constraint clauses from a list of constraints -fn extract_check_clauses(constraints: &[TableConstraint]) -> Vec { - constraints - .iter() - .filter_map(|c| { - if let TableConstraint::Check { name, expr } = c { - Some(format!("CONSTRAINT \"{}\" CHECK ({})", name, expr)) - } else { - None - } - }) - .collect() -} - -/// Build CREATE TABLE query with CHECK constraints properly embedded -fn build_create_with_checks( - backend: &DatabaseBackend, - create_stmt: &sea_query::TableCreateStatement, - check_clauses: &[String], -) -> BuiltQuery { - if check_clauses.is_empty() { - BuiltQuery::CreateTable(Box::new(create_stmt.clone())) - } else { - let base_sql = build_schema_statement(create_stmt, *backend); - let mut modified_sql = base_sql; - if let Some(pos) = modified_sql.rfind(')') { - let check_sql = check_clauses.join(", "); - modified_sql.insert_str(pos, &format!(", {}", check_sql)); - } - BuiltQuery::Raw(RawSql::per_backend( - modified_sql.clone(), - modified_sql.clone(), - modified_sql, - )) - } -} pub fn build_add_constraint( backend: &DatabaseBackend, @@ -63,22 +26,17 @@ pub fn build_add_constraint( let mut new_constraints = table_def.constraints.clone(); new_constraints.push(constraint.clone()); - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table with new constraints - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table with new constraints + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - // Handle CHECK constraints (sea-query doesn't support them natively) - let check_clauses = extract_check_clauses(&new_constraints); - let create_query = - build_create_with_checks(backend, &create_temp_table, &check_clauses); - // 2. Copy data let column_aliases: Vec = table_def .columns @@ -106,28 +64,8 @@ pub fn build_add_constraint( // 4. Rename temporary table let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for c in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = c - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -154,7 +92,13 @@ pub fn build_add_constraint( } } TableConstraint::Unique { name, columns } => { - // SQLite does not support ALTER TABLE ... ADD CONSTRAINT UNIQUE + // On SQLite, skip if this constraint already exists in the schema — + // a prior temp table rebuild in the same migration already recreated it. + if *backend == DatabaseBackend::Sqlite + && let Some(table_def) = current_schema.iter().find(|t| t.name == table) + && table_def.constraints.contains(constraint) { + return Ok(vec![]); + } // Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns} let index_name = super::helpers::build_unique_constraint_name(table, columns, name.as_deref()); @@ -182,25 +126,38 @@ pub fn build_add_constraint( let table_def = current_schema.iter().find(|t| t.name == table).ok_or_else(|| QueryError::Other(format!("Table '{}' not found in current schema. SQLite requires current schema information to add constraints.", table)))?; // Create new constraints with the added foreign key constraint + // Dedup: check if this FK already exists (e.g. from inline normalization) let mut new_constraints = table_def.constraints.clone(); - new_constraints.push(constraint.clone()); + let fk_already_exists = new_constraints.iter().any(|c| { + if let TableConstraint::ForeignKey { + columns: existing_cols, + ref_table: existing_ref, + ref_columns: existing_ref_cols, + .. + } = c + { + columns == existing_cols + && ref_table == existing_ref + && ref_columns == existing_ref_cols + } else { + false + } + }); + if !fk_already_exists { + new_constraints.push(constraint.clone()); + } - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table with new constraints - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table with new constraints + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - // Handle CHECK constraints (sea-query doesn't support them natively) - let check_clauses = extract_check_clauses(&new_constraints); - let create_query = - build_create_with_checks(backend, &create_temp_table, &check_clauses); - // 2. Copy data (all columns) let column_aliases: Vec = table_def .columns @@ -228,28 +185,8 @@ pub fn build_add_constraint( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for c in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = c - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -278,7 +215,13 @@ pub fn build_add_constraint( } } TableConstraint::Index { name, columns } => { - // Index constraints are simple CREATE INDEX statements for all backends + // On SQLite, skip if this constraint already exists in the schema — + // a prior temp table rebuild in the same migration already recreated it. + if *backend == DatabaseBackend::Sqlite + && let Some(table_def) = current_schema.iter().find(|t| t.name == table) + && table_def.constraints.contains(constraint) { + return Ok(vec![]); + } let index_name = vespertide_naming::build_index_name(table, columns, name.as_deref()); let mut idx = Index::create() .table(Alias::new(table)) @@ -299,22 +242,17 @@ pub fn build_add_constraint( let mut new_constraints = table_def.constraints.clone(); new_constraints.push(constraint.clone()); - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table with new constraints - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table with new constraints + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - // Handle CHECK constraints (sea-query doesn't support them natively) - let check_clauses = extract_check_clauses(&new_constraints); - let create_query = - build_create_with_checks(backend, &create_temp_table, &check_clauses); - // 2. Copy data (all columns) let column_aliases: Vec = table_def .columns @@ -342,28 +280,8 @@ pub fn build_add_constraint( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for c in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = c - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -1130,7 +1048,7 @@ mod tests { columns: vec!["email".into()], }, ]; - let clauses = extract_check_clauses(&constraints); + let clauses = crate::sql::helpers::extract_check_clauses(&constraints); assert_eq!(clauses.len(), 2); assert!(clauses[0].contains("chk1")); assert!(clauses[1].contains("chk2")); @@ -1148,13 +1066,13 @@ mod tests { columns: vec!["email".into()], }, ]; - let clauses = extract_check_clauses(&constraints); + let clauses = crate::sql::helpers::extract_check_clauses(&constraints); assert!(clauses.is_empty()); } #[test] fn test_build_create_with_checks_empty_clauses() { - use super::build_create_table_for_backend; + use crate::sql::create_table::build_create_table_for_backend; let create_stmt = build_create_table_for_backend( &DatabaseBackend::Sqlite, @@ -1174,14 +1092,18 @@ mod tests { ); // Empty check_clauses should return CreateTable variant - let result = build_create_with_checks(&DatabaseBackend::Sqlite, &create_stmt, &[]); + let result = crate::sql::helpers::build_create_with_checks( + &DatabaseBackend::Sqlite, + &create_stmt, + &[], + ); let sql = result.build(DatabaseBackend::Sqlite); assert!(sql.contains("CREATE TABLE")); } #[test] fn test_build_create_with_checks_with_clauses() { - use super::build_create_table_for_backend; + use crate::sql::create_table::build_create_table_for_backend; let create_stmt = build_create_table_for_backend( &DatabaseBackend::Sqlite, @@ -1202,8 +1124,11 @@ mod tests { // Non-empty check_clauses should return Raw variant with embedded checks let check_clauses = vec!["CONSTRAINT \"chk1\" CHECK (id > 0)".to_string()]; - let result = - build_create_with_checks(&DatabaseBackend::Sqlite, &create_stmt, &check_clauses); + let result = crate::sql::helpers::build_create_with_checks( + &DatabaseBackend::Sqlite, + &create_stmt, + &check_clauses, + ); let sql = result.build(DatabaseBackend::Sqlite); assert!(sql.contains("CREATE TABLE")); assert!(sql.contains("CONSTRAINT \"chk1\" CHECK (id > 0)")); diff --git a/crates/vespertide-query/src/sql/create_table.rs b/crates/vespertide-query/src/sql/create_table.rs index 15270ce..15b8131 100644 --- a/crates/vespertide-query/src/sql/create_table.rs +++ b/crates/vespertide-query/src/sql/create_table.rs @@ -48,7 +48,12 @@ pub(crate) fn build_create_table_for_backend( } // Apply auto_increment if this column is in the auto_increment primary key - if auto_increment_columns.contains(column.name.as_str()) { + // and the column type supports it (integer types only). + // After ModifyColumnType, the PK may still have auto_increment: true but the + // column type may have changed to a non-integer type (e.g. varchar). + if auto_increment_columns.contains(column.name.as_str()) + && column.r#type.supports_auto_increment() + { // For SQLite, AUTOINCREMENT requires inline PRIMARY KEY (INTEGER PRIMARY KEY AUTOINCREMENT) // So we must call primary_key() on the column even if there's a table-level PRIMARY KEY if matches!(backend, DatabaseBackend::Sqlite) { @@ -72,8 +77,17 @@ pub(crate) fn build_create_table_for_backend( auto_increment, } => { // For SQLite with auto_increment, skip table-level PRIMARY KEY - // because AUTOINCREMENT requires inline PRIMARY KEY on the column - if matches!(backend, DatabaseBackend::Sqlite) && *auto_increment { + // because AUTOINCREMENT requires inline PRIMARY KEY on the column. + // But only if the PK column actually supports auto_increment (integer types). + if matches!(backend, DatabaseBackend::Sqlite) + && *auto_increment + && pk_cols.iter().all(|col_name| { + columns + .iter() + .find(|c| c.name == *col_name) + .is_some_and(|c| c.r#type.supports_auto_increment()) + }) + { continue; } // Build primary key index diff --git a/crates/vespertide-query/src/sql/delete_column.rs b/crates/vespertide-query/src/sql/delete_column.rs index 9b3ec7f..b94d239 100644 --- a/crates/vespertide-query/src/sql/delete_column.rs +++ b/crates/vespertide-query/src/sql/delete_column.rs @@ -2,8 +2,9 @@ use sea_query::{Alias, Index, Query, Table}; use vespertide_core::{ColumnType, TableConstraint, TableDef}; -use super::create_table::build_create_table_for_backend; -use super::helpers::build_drop_enum_type_sql; +use super::helpers::{ + build_drop_enum_type_sql, build_sqlite_temp_table_create, recreate_indexes_after_rebuild, +}; use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend}; @@ -27,11 +28,38 @@ pub fn build_delete_column( if *backend == DatabaseBackend::Sqlite && let Some(table_def) = current_schema.iter().find(|t| t.name == table) { + // If the column has an enum type, SQLite embeds a CHECK constraint in CREATE TABLE. + // ALTER TABLE DROP COLUMN fails if the column is referenced by any CHECK. + // Must use temp table approach. + if let Some(col_def) = table_def.columns.iter().find(|c| c.name == column) + && let ColumnType::Complex(vespertide_core::ComplexColumnType::Enum { .. }) = + &col_def.r#type + { + return build_delete_column_sqlite_temp_table( + table, + column, + table_def, + column_type, + ); + } + // Handle constraints referencing the deleted column for constraint in &table_def.constraints { match constraint { - // Check constraints are expression-based, not column-based - skip - TableConstraint::Check { .. } => continue, + // Check constraints may reference the column in their expression. + // SQLite can't DROP COLUMN if a CHECK references it — use temp table. + TableConstraint::Check { expr, .. } => { + // Check if the expression references the column (e.g. "status" IN (...)) + if expr.contains(&format!("\"{}\"", column)) || expr.contains(column) { + return build_delete_column_sqlite_temp_table( + table, + column, + table_def, + column_type, + ); + } + continue; + } // For column-based constraints, check if they reference the deleted column _ if !constraint.columns().iter().any(|c| c == column) => continue, // FK/PK require temp table approach - return immediately @@ -116,18 +144,25 @@ fn build_delete_column_sqlite_temp_table( let new_constraints: Vec<_> = table_def .constraints .iter() - .filter(|c| !c.columns().iter().any(|col| col == column)) + .filter(|c| { + // For CHECK constraints, check if expression references the column + if let TableConstraint::Check { expr, .. } = c { + return !expr.contains(&format!("\"{}\"", column)) && !expr.contains(column); + } + !c.columns().iter().any(|col| col == column) + }) .cloned() .collect(); - // 1. Create temp table without the column - let create_temp_table = build_create_table_for_backend( + // 1. Create temp table without the column + CHECK constraints + let create_query = build_sqlite_temp_table_create( &DatabaseBackend::Sqlite, &temp_table, + table, &new_columns, &new_constraints, ); - stmts.push(BuiltQuery::CreateTable(Box::new(create_temp_table))); + stmts.push(create_query); // 2. Copy data (excluding the deleted column) let column_aliases: Vec = new_columns.iter().map(|c| Alias::new(&c.name)).collect(); @@ -152,21 +187,8 @@ fn build_delete_column_sqlite_temp_table( // 4. Rename temp table to original name stmts.push(build_rename_table(&temp_table, table)); - // 5. Recreate indexes that don't reference the deleted column - for constraint in &new_constraints { - if let TableConstraint::Index { name, columns } = constraint { - let index_name = vespertide_naming::build_index_name(table, columns, name.as_deref()); - let mut idx_stmt = Index::create(); - idx_stmt = idx_stmt - .name(&index_name) - .table(Alias::new(table)) - .to_owned(); - for col_name in columns { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - stmts.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) that don't reference the deleted column + stmts.extend(recreate_indexes_after_rebuild(table, &new_constraints)); // If column type is an enum, drop the type after (PostgreSQL only, but include for completeness) if let Some(col_type) = column_type @@ -1029,9 +1051,9 @@ mod tests { } #[test] - fn test_delete_column_sqlite_with_check_constraint_skipped() { - // Check constraints are expression-based, not column-based. - // They should be skipped (continue) during constraint iteration. + fn test_delete_column_sqlite_with_check_constraint_referencing_column() { + // When a CHECK constraint references the column being deleted, + // SQLite can't use ALTER TABLE DROP COLUMN — must use temp table approach. let schema = vec![TableDef { name: "orders".into(), description: None, @@ -1045,16 +1067,56 @@ mod tests { }], }]; - // Delete amount column - Check constraint should be skipped (not trigger temp table) + // Delete amount column — CHECK references it, so temp table is needed let result = build_delete_column(&DatabaseBackend::Sqlite, "orders", "amount", None, &schema); - // Should have only 1 statement: ALTER TABLE DROP COLUMN - // (Check constraint doesn't require special handling) + // Should use temp table approach (CREATE temp, INSERT, DROP, RENAME) + assert!( + result.len() >= 4, + "Expected temp table approach (>=4 stmts), got: {} statements", + result.len() + ); + + let sql = result[0].build(DatabaseBackend::Sqlite); + assert!( + sql.contains("orders_temp"), + "Expected temp table creation, got: {}", + sql + ); + // The CHECK constraint referencing "amount" should NOT be in the temp table + assert!( + !sql.contains("check_positive"), + "CHECK referencing deleted column should be removed, got: {}", + sql + ); + } + + #[test] + fn test_delete_column_sqlite_with_check_constraint_not_referencing_column() { + // When a CHECK constraint does NOT reference the column being deleted, + // simple DROP COLUMN should work. + let schema = vec![TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("amount", ColumnType::Simple(SimpleColumnType::Integer)), + col("note", ColumnType::Simple(SimpleColumnType::Text)), + ], + constraints: vec![TableConstraint::Check { + name: "check_positive".into(), + expr: "amount > 0".into(), + }], + }]; + + // Delete "note" column — CHECK only references "amount", not "note" + let result = build_delete_column(&DatabaseBackend::Sqlite, "orders", "note", None, &schema); + assert_eq!( result.len(), 1, - "Check constraint should be skipped, got: {} statements", + "Unrelated CHECK should be skipped, got: {} statements", result.len() ); diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 84d3d16..34eb27f 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -4,10 +4,11 @@ use sea_query::{ }; use vespertide_core::{ - ColumnDef, ColumnType, ComplexColumnType, ReferenceAction, SimpleColumnType, + ColumnDef, ColumnType, ComplexColumnType, ReferenceAction, SimpleColumnType, TableConstraint, }; -use super::types::DatabaseBackend; +use super::create_table::build_create_table_for_backend; +use super::types::{BuiltQuery, DatabaseBackend, RawSql}; /// Normalize fill_with value - empty string becomes '' (SQL empty string literal) pub fn normalize_fill_with(fill_with: Option<&str>) -> Option { @@ -370,6 +371,132 @@ pub fn collect_sqlite_enum_check_clauses(table: &str, columns: &[ColumnDef]) -> .collect() } +/// Extract CHECK constraint clauses from a list of table constraints. +/// Returns SQL fragments like: `CONSTRAINT "chk_name" CHECK (expr)` +pub fn extract_check_clauses(constraints: &[TableConstraint]) -> Vec { + constraints + .iter() + .filter_map(|c| { + if let TableConstraint::Check { name, expr } = c { + Some(format!("CONSTRAINT \"{}\" CHECK ({})", name, expr)) + } else { + None + } + }) + .collect() +} + +/// Collect ALL CHECK constraint clauses for a SQLite temp table. +/// Combines both: +/// - Enum-based CHECK constraints (from column types) +/// - Explicit CHECK constraints (from `TableConstraint::Check`) +/// Returns deduplicated union of both. +pub fn collect_all_check_clauses( + table: &str, + columns: &[ColumnDef], + constraints: &[TableConstraint], +) -> Vec { + let mut clauses = collect_sqlite_enum_check_clauses(table, columns); + let explicit = extract_check_clauses(constraints); + for clause in explicit { + if !clauses.contains(&clause) { + clauses.push(clause); + } + } + clauses +} + +/// Build CREATE TABLE query with CHECK constraints properly embedded. +/// sea-query doesn't support CHECK constraints natively, so we inject them +/// by modifying the generated SQL string. +pub fn build_create_with_checks( + backend: &DatabaseBackend, + create_stmt: &sea_query::TableCreateStatement, + check_clauses: &[String], +) -> BuiltQuery { + if check_clauses.is_empty() { + BuiltQuery::CreateTable(Box::new(create_stmt.clone())) + } else { + let base_sql = build_schema_statement(create_stmt, *backend); + let mut modified_sql = base_sql; + if let Some(pos) = modified_sql.rfind(')') { + let check_sql = check_clauses.join(", "); + modified_sql.insert_str(pos, &format!(", {}", check_sql)); + } + BuiltQuery::Raw(RawSql::per_backend( + modified_sql.clone(), + modified_sql.clone(), + modified_sql, + )) + } +} + +/// Build the CREATE TABLE statement for a SQLite temp table, including all CHECK constraints. +/// This combines `build_create_table_for_backend` with CHECK constraint injection. +/// +/// `table` is the ORIGINAL table name (used for constraint naming). +/// `temp_table` is the temporary table name. +pub fn build_sqlite_temp_table_create( + backend: &DatabaseBackend, + temp_table: &str, + table: &str, + columns: &[ColumnDef], + constraints: &[TableConstraint], +) -> BuiltQuery { + let create_stmt = build_create_table_for_backend(backend, temp_table, columns, constraints); + let check_clauses = collect_all_check_clauses(table, columns, constraints); + build_create_with_checks(backend, &create_stmt, &check_clauses) +} + +/// Recreate all indexes (both regular and UNIQUE) after a SQLite temp table rebuild. +/// After DROP TABLE + RENAME, all original indexes are gone, so plain CREATE INDEX is correct. +pub fn recreate_indexes_after_rebuild( + table: &str, + constraints: &[TableConstraint], +) -> Vec { + let mut queries = Vec::new(); + for constraint in constraints { + match constraint { + TableConstraint::Index { name, columns } => { + let index_name = build_index_name(table, columns, name.as_deref()); + let cols_sql = columns + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + let sql = format!( + "CREATE INDEX \"{}\" ON \"{}\" ({})", + index_name, table, cols_sql + ); + queries.push(BuiltQuery::Raw(RawSql::per_backend( + sql.clone(), + sql.clone(), + sql, + ))); + } + TableConstraint::Unique { name, columns } => { + let index_name = build_unique_constraint_name(table, columns, name.as_deref()); + let cols_sql = columns + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + let sql = format!( + "CREATE UNIQUE INDEX \"{}\" ON \"{}\" ({})", + index_name, table, cols_sql + ); + queries.push(BuiltQuery::Raw(RawSql::per_backend( + sql.clone(), + sql.clone(), + sql, + ))); + } + _ => {} + } + } + queries +} + /// Extract enum name from column type if it's an enum pub fn get_enum_name(column_type: &ColumnType) -> Option<&str> { if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = column_type { diff --git a/crates/vespertide-query/src/sql/modify_column_default.rs b/crates/vespertide-query/src/sql/modify_column_default.rs index df85237..2af382e 100644 --- a/crates/vespertide-query/src/sql/modify_column_default.rs +++ b/crates/vespertide-query/src/sql/modify_column_default.rs @@ -2,8 +2,10 @@ use sea_query::{Alias, Query, Table}; use vespertide_core::{ColumnDef, TableDef}; -use super::create_table::build_create_table_for_backend; -use super::helpers::{build_sea_column_def_with_table, normalize_enum_default}; +use super::helpers::{ + build_sea_column_def_with_table, build_sqlite_temp_table_create, normalize_enum_default, + recreate_indexes_after_rebuild, +}; use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend, RawSql}; use crate::error::QueryError; @@ -99,14 +101,15 @@ pub fn build_modify_column_default( // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table with modified column - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table with modified column + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &new_columns, &table_def.constraints, ); - queries.push(BuiltQuery::CreateTable(Box::new(create_temp_table))); + queries.push(create_query); // 2. Copy data (all columns) let column_aliases: Vec = table_def @@ -135,24 +138,11 @@ pub fn build_modify_column_default( // 4. Rename temporary table to original name queries.push(build_rename_table(&temp_table, table)); - // 5. Recreate indexes from Index constraints - for constraint in &table_def.constraints { - if let vespertide_core::TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = constraint - { - let index_name = - vespertide_naming::build_index_name(table, idx_cols, idx_name.as_deref()); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + queries.extend(recreate_indexes_after_rebuild( + table, + &table_def.constraints, + )); } } diff --git a/crates/vespertide-query/src/sql/modify_column_nullable.rs b/crates/vespertide-query/src/sql/modify_column_nullable.rs index 26a5f26..e6be72b 100644 --- a/crates/vespertide-query/src/sql/modify_column_nullable.rs +++ b/crates/vespertide-query/src/sql/modify_column_nullable.rs @@ -2,8 +2,10 @@ use sea_query::{Alias, Query, Table}; use vespertide_core::{ColumnDef, TableDef}; -use super::create_table::build_create_table_for_backend; -use super::helpers::{build_sea_column_def_with_table, normalize_fill_with}; +use super::helpers::{ + build_sea_column_def_with_table, build_sqlite_temp_table_create, normalize_fill_with, + recreate_indexes_after_rebuild, +}; use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend, RawSql}; use crate::error::QueryError; @@ -87,14 +89,15 @@ pub fn build_modify_column_nullable( // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table with modified column - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table with modified column + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &new_columns, &table_def.constraints, ); - queries.push(BuiltQuery::CreateTable(Box::new(create_temp_table))); + queries.push(create_query); // 2. Copy data (all columns) let column_aliases: Vec = table_def @@ -123,24 +126,11 @@ pub fn build_modify_column_nullable( // 4. Rename temporary table to original name queries.push(build_rename_table(&temp_table, table)); - // 5. Recreate indexes from Index constraints - for constraint in &table_def.constraints { - if let vespertide_core::TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = constraint - { - let index_name = - vespertide_naming::build_index_name(table, idx_cols, idx_name.as_deref()); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + queries.extend(recreate_indexes_after_rebuild( + table, + &table_def.constraints, + )); } } diff --git a/crates/vespertide-query/src/sql/modify_column_type.rs b/crates/vespertide-query/src/sql/modify_column_type.rs index 7fea5d1..f9e838f 100644 --- a/crates/vespertide-query/src/sql/modify_column_type.rs +++ b/crates/vespertide-query/src/sql/modify_column_type.rs @@ -2,10 +2,9 @@ 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_with_table, build_create_enum_type_sql, convert_default_for_backend, - normalize_enum_default, + apply_column_type_with_table, build_create_enum_type_sql, build_sqlite_temp_table_create, + convert_default_for_backend, normalize_enum_default, recreate_indexes_after_rebuild, }; use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend}; @@ -40,14 +39,14 @@ pub fn build_modify_column_type( // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table with new column types - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table with new column types + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &new_columns, &table_def.constraints, ); - let create_query = BuiltQuery::CreateTable(Box::new(create_temp_table)); // 2. Copy data (all columns) - Use INSERT INTO ... SELECT let column_aliases: Vec = new_columns.iter().map(|c| Alias::new(&c.name)).collect(); @@ -76,21 +75,8 @@ pub fn build_modify_column_type( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - 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 mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in columns { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); diff --git a/crates/vespertide-query/src/sql/remove_constraint.rs b/crates/vespertide-query/src/sql/remove_constraint.rs index d2cc1bb..22134d3 100644 --- a/crates/vespertide-query/src/sql/remove_constraint.rs +++ b/crates/vespertide-query/src/sql/remove_constraint.rs @@ -2,7 +2,7 @@ use sea_query::{Alias, ForeignKey, Query, Table}; use vespertide_core::{TableConstraint, TableDef}; -use super::create_table::build_create_table_for_backend; +use super::helpers::{build_sqlite_temp_table_create, recreate_indexes_after_rebuild}; use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend}; use crate::error::QueryError; @@ -24,17 +24,16 @@ pub fn build_remove_constraint( let mut new_constraints = table_def.constraints.clone(); new_constraints.retain(|c| !matches!(c, TableConstraint::PrimaryKey { .. })); - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table without primary key constraint - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table without primary key constraint + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - let create_query = BuiltQuery::CreateTable(Box::new(create_temp_table)); // 2. Copy data (all columns) let column_aliases: Vec = table_def @@ -63,28 +62,8 @@ pub fn build_remove_constraint( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for constraint in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = constraint - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -134,17 +113,16 @@ pub fn build_remove_constraint( } }); - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table without the removed constraint - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table without the removed constraint + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - let create_query = BuiltQuery::CreateTable(Box::new(create_temp_table)); // 2. Copy data (all columns) let column_aliases: Vec = table_def @@ -173,28 +151,8 @@ pub fn build_remove_constraint( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for c in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = c - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE, from new_constraints which has the unique removed) + let index_queries = recreate_indexes_after_rebuild(table, &new_constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -250,17 +208,16 @@ pub fn build_remove_constraint( } }); - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table without the removed constraint - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table without the removed constraint + CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - let create_query = BuiltQuery::CreateTable(Box::new(create_temp_table)); // 2. Copy data (all columns) let column_aliases: Vec = table_def @@ -289,28 +246,8 @@ pub fn build_remove_constraint( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for c in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = c - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -357,17 +294,16 @@ pub fn build_remove_constraint( _ => true, }); - // Generate temporary table name let temp_table = format!("{}_temp", table); - // 1. Create temporary table without the removed constraint - let create_temp_table = build_create_table_for_backend( + // 1. Create temporary table without the removed constraint + remaining CHECK constraints + let create_query = build_sqlite_temp_table_create( backend, &temp_table, + table, &table_def.columns, &new_constraints, ); - let create_query = BuiltQuery::CreateTable(Box::new(create_temp_table)); // 2. Copy data (all columns) let column_aliases: Vec = table_def @@ -396,28 +332,8 @@ pub fn build_remove_constraint( // 4. Rename temporary table to original name let rename_query = build_rename_table(&temp_table, table); - // 5. Recreate indexes from Index constraints - let mut index_queries = Vec::new(); - for c in &table_def.constraints { - if let TableConstraint::Index { - name: idx_name, - columns: idx_cols, - } = c - { - let index_name = vespertide_naming::build_index_name( - table, - idx_cols, - idx_name.as_deref(), - ); - let mut idx_stmt = sea_query::Index::create(); - idx_stmt = idx_stmt.name(&index_name).to_owned(); - for col_name in idx_cols { - idx_stmt = idx_stmt.col(Alias::new(col_name)).to_owned(); - } - idx_stmt = idx_stmt.table(Alias::new(table)).to_owned(); - index_queries.push(BuiltQuery::CreateIndex(Box::new(idx_stmt))); - } - } + // 5. Recreate indexes (both regular and UNIQUE) + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_column_type_with_unique_constraint@modify_column_type_with_unique_constraint_modify_column_type_with_unique_constraint_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_column_type_with_unique_constraint@modify_column_type_with_unique_constraint_modify_column_type_with_unique_constraint_sqlite.snap index 742b4a4..5500e3d 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_column_type_with_unique_constraint@modify_column_type_with_unique_constraint_modify_column_type_with_unique_constraint_sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_column_type_with_unique_constraint@modify_column_type_with_unique_constraint_modify_column_type_with_unique_constraint_sqlite.snap @@ -5,4 +5,5 @@ expression: sql CREATE TABLE "users_temp" ( "id" integer NOT NULL, "email" varchar(255) ); INSERT INTO "users_temp" ("id", "email") SELECT "id", "email" FROM "users"; DROP TABLE "users"; -ALTER TABLE "users_temp" RENAME TO "users" +ALTER TABLE "users_temp" RENAME TO "users"; +CREATE UNIQUE INDEX "uq_users__uq_email" ON "users" ("email") 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_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_sqlite.snap index edd151e..4d1cc9b 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_name_changed_sqlite.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TABLE "users_temp" ( "status" enum_text ); +CREATE TABLE "users_temp" ( "status" enum_text , CONSTRAINT "chk_users__status" CHECK ("status" IN ('active', 'inactive'))); INSERT INTO "users_temp" ("status") SELECT "status" FROM "users"; DROP TABLE "users"; ALTER TABLE "users_temp" RENAME TO "users" 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_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_sqlite.snap index edd151e..4d1cc9b 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_same_values_sqlite.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TABLE "users_temp" ( "status" enum_text ); +CREATE TABLE "users_temp" ( "status" enum_text , CONSTRAINT "chk_users__status" CHECK ("status" IN ('active', 'inactive'))); INSERT INTO "users_temp" ("status") SELECT "status" FROM "users"; DROP TABLE "users"; ALTER TABLE "users_temp" RENAME TO "users" 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_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_sqlite.snap index edd151e..7e0f3b2 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_enum_values_changed_sqlite.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TABLE "users_temp" ( "status" enum_text ); +CREATE TABLE "users_temp" ( "status" enum_text , CONSTRAINT "chk_users__status" CHECK ("status" IN ('active', 'inactive', 'pending'))); INSERT INTO "users_temp" ("status") SELECT "status" FROM "users"; DROP TABLE "users"; ALTER TABLE "users_temp" RENAME TO "users" 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_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_sqlite.snap index edd151e..4d1cc9b 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_types@modify_enum_types_text_to_enum_sqlite.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TABLE "users_temp" ( "status" enum_text ); +CREATE TABLE "users_temp" ( "status" enum_text , CONSTRAINT "chk_users__status" CHECK ("status" IN ('active', 'inactive'))); INSERT INTO "users_temp" ("status") SELECT "status" FROM "users"; DROP TABLE "users"; ALTER TABLE "users_temp" RENAME TO "users" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_with_default_value@modify_enum_with_default_modify_enum_with_default_sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_with_default_value@modify_enum_with_default_modify_enum_with_default_sqlite.snap index deea7b4..4f55f29 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_with_default_value@modify_enum_with_default_modify_enum_with_default_sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__modify_column_type__tests__modify_enum_with_default_value@modify_enum_with_default_modify_enum_with_default_sqlite.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/modify_column_type.rs expression: sql --- -CREATE TABLE "reservation_session_temp" ( "status" enum_text NOT NULL DEFAULT 'pending' ); +CREATE TABLE "reservation_session_temp" ( "status" enum_text NOT NULL DEFAULT 'pending' , CONSTRAINT "chk_reservation_session__status" CHECK ("status" IN ('pending', 'confirmed', 'cancelled'))); INSERT INTO "reservation_session_temp" ("status") SELECT "status" FROM "reservation_session"; DROP TABLE "reservation_session"; ALTER TABLE "reservation_session_temp" RENAME TO "reservation_session" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_other_constraints@remove_check_with_other_constraints_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_other_constraints@remove_check_with_other_constraints_Sqlite.snap index f3aefe9..6ab30be 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_other_constraints@remove_check_with_other_constraints_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_other_constraints@remove_check_with_other_constraints_Sqlite.snap @@ -6,3 +6,4 @@ CREATE TABLE "users_temp" ( "id" integer NOT NULL, "age" integer, PRIMARY KEY (" INSERT INTO "users_temp" ("id", "age") SELECT "id", "age" FROM "users" DROP TABLE "users" ALTER TABLE "users_temp" RENAME TO "users" +CREATE UNIQUE INDEX "uq_users__uq_age" ON "users" ("age") diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_unique_constraint@remove_check_with_unique_constraint_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_unique_constraint@remove_check_with_unique_constraint_Sqlite.snap index bd54566..7f7e380 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_unique_constraint@remove_check_with_unique_constraint_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_check_with_unique_constraint@remove_check_with_unique_constraint_Sqlite.snap @@ -6,3 +6,4 @@ CREATE TABLE "users_temp" ( "id" integer NOT NULL, "age" integer ) INSERT INTO "users_temp" ("id", "age") SELECT "id", "age" FROM "users" DROP TABLE "users" ALTER TABLE "users_temp" RENAME TO "users" +CREATE UNIQUE INDEX "uq_users__uq_age" ON "users" ("age") diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_other_constraints@remove_foreign_key_with_other_constraints_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_other_constraints@remove_foreign_key_with_other_constraints_Sqlite.snap index b50b776..8a8e281 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_other_constraints@remove_foreign_key_with_other_constraints_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_other_constraints@remove_foreign_key_with_other_constraints_Sqlite.snap @@ -2,7 +2,8 @@ source: crates/vespertide-query/src/sql/remove_constraint.rs expression: sql --- -CREATE TABLE "posts_temp" ( "id" integer NOT NULL, "user_id" integer, PRIMARY KEY ("id") ) +CREATE TABLE "posts_temp" ( "id" integer NOT NULL, "user_id" integer, PRIMARY KEY ("id") , CONSTRAINT "chk_user_id" CHECK (user_id > 0)) INSERT INTO "posts_temp" ("id", "user_id") SELECT "id", "user_id" FROM "posts" DROP TABLE "posts" ALTER TABLE "posts_temp" RENAME TO "posts" +CREATE UNIQUE INDEX "uq_posts__uq_user_id" ON "posts" ("user_id") diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_unique_constraint@remove_foreign_key_with_unique_constraint_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_unique_constraint@remove_foreign_key_with_unique_constraint_Sqlite.snap index 021f502..773a436 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_unique_constraint@remove_foreign_key_with_unique_constraint_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_foreign_key_with_unique_constraint@remove_foreign_key_with_unique_constraint_Sqlite.snap @@ -6,3 +6,4 @@ CREATE TABLE "posts_temp" ( "id" integer NOT NULL, "user_id" integer ) INSERT INTO "posts_temp" ("id", "user_id") SELECT "id", "user_id" FROM "posts" DROP TABLE "posts" ALTER TABLE "posts_temp" RENAME TO "posts" +CREATE UNIQUE INDEX "uq_posts__uq_user_id" ON "posts" ("user_id") diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_primary_key_with_unique_constraint@remove_primary_key_with_unique_constraint_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_primary_key_with_unique_constraint@remove_primary_key_with_unique_constraint_Sqlite.snap index 9cb9e58..8bc90de 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_primary_key_with_unique_constraint@remove_primary_key_with_unique_constraint_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_primary_key_with_unique_constraint@remove_primary_key_with_unique_constraint_Sqlite.snap @@ -6,3 +6,4 @@ CREATE TABLE "users_temp" ( "id" integer NOT NULL ) INSERT INTO "users_temp" ("id") SELECT "id" FROM "users" DROP TABLE "users" ALTER TABLE "users_temp" RENAME TO "users" +CREATE UNIQUE INDEX "uq_users__uq_email" ON "users" ("email") diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_constraints@remove_unique_with_other_constraints_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_constraints@remove_unique_with_other_constraints_Sqlite.snap index 12998e0..f4eda57 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_constraints@remove_unique_with_other_constraints_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_constraints@remove_unique_with_other_constraints_Sqlite.snap @@ -2,7 +2,7 @@ source: crates/vespertide-query/src/sql/remove_constraint.rs expression: sql --- -CREATE TABLE "users_temp" ( "id" integer NOT NULL, "email" text, PRIMARY KEY ("id") ) +CREATE TABLE "users_temp" ( "id" integer NOT NULL, "email" text, PRIMARY KEY ("id") , CONSTRAINT "chk_email" CHECK (email IS NOT NULL)) INSERT INTO "users_temp" ("id", "email") SELECT "id", "email" FROM "users" DROP TABLE "users" ALTER TABLE "users_temp" RENAME TO "users" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_unique_constraint@remove_unique_with_other_unique_constraint_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_unique_constraint@remove_unique_with_other_unique_constraint_Sqlite.snap index 673a40b..200def4 100644 --- a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_unique_constraint@remove_unique_with_other_unique_constraint_Sqlite.snap +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__remove_constraint__tests__remove_constraint_unique_with_other_unique_constraint@remove_unique_with_other_unique_constraint_Sqlite.snap @@ -6,3 +6,4 @@ CREATE TABLE "users_temp" ( "id" integer NOT NULL, "email" text ) INSERT INTO "users_temp" ("id", "email") SELECT "id", "email" FROM "users" DROP TABLE "users" ALTER TABLE "users_temp" RENAME TO "users" +CREATE UNIQUE INDEX "uq_users__uq_name" ON "users" ("name") From cf283c98931b7399407b93251632be383c5d91d2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 17:24:47 +0900 Subject: [PATCH 03/17] Fix tx issue --- crates/vespertide-macro/src/lib.rs | 54 +++++++++++++++--------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 6a49a3f..14451c7 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -11,7 +11,7 @@ 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}; +use vespertide_query::{build_plan_queries, DatabaseBackend}; struct MacroInput { pool: Expr, @@ -90,12 +90,7 @@ pub(crate) fn build_migration_block( // Generate version guard and SQL execution block let block = quote! { - if version < #version { - // Begin transaction - let txn = __pool.begin().await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e)) - })?; - + if __version < #version { // Select SQL statements based on backend let sqls: &[&str] = match backend { sea_orm::DatabaseBackend::Postgres => &[#(#pg_sqls),*], @@ -108,24 +103,18 @@ pub(crate) fn build_migration_block( for sql in sqls { if !sql.is_empty() { let stmt = sea_orm::Statement::from_string(backend, *sql); - txn.execute_raw(stmt).await.map_err(|e| { + __txn.execute_raw(stmt).await.map_err(|e| { ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) })?; } } // Insert version record for this migration - let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '"' }; - let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", version_table, #version); + let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", __version_table, #version); let stmt = sea_orm::Statement::from_string(backend, insert_sql); - txn.execute_raw(stmt).await.map_err(|e| { + __txn.execute_raw(stmt).await.map_err(|e| { ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) })?; - - // Commit transaction - txn.commit().await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e)) - })?; } }; @@ -141,36 +130,47 @@ pub(crate) fn generate_migration_code( quote! { async { use sea_orm::{ConnectionTrait, TransactionTrait}; - let __pool = #pool; - let version_table = #version_table; + let __pool = &#pool; + let __version_table = #version_table; let backend = __pool.get_database_backend(); - - // Create version table if it does not exist - // Table structure: version (INTEGER PRIMARY KEY), created_at (timestamp) let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '"' }; + + // Create version table if it does not exist (outside transaction) let create_table_sql = format!( "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", - version_table + __version_table ); let stmt = sea_orm::Statement::from_string(backend, create_table_sql); __pool.execute_raw(stmt).await.map_err(|e| { ::vespertide::MigrationError::DatabaseError(format!("Failed to create version table: {}", e)) })?; - // Read current maximum version (latest applied migration) - let select_sql = format!("SELECT MAX(version) as version FROM {q}{}{q}", version_table); + // Single transaction for the entire migration process. + // This prevents race conditions when multiple connections exist + // (e.g. SQLite with max_connections > 1). + let __txn = __pool.begin().await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e)) + })?; + + // Read current maximum version inside the transaction (holds lock) + let select_sql = format!("SELECT MAX(version) as version FROM {q}{}{q}", __version_table); let stmt = sea_orm::Statement::from_string(backend, select_sql); - let version_result = __pool.query_one_raw(stmt).await.map_err(|e| { + let version_result = __txn.query_one_raw(stmt).await.map_err(|e| { ::vespertide::MigrationError::DatabaseError(format!("Failed to read version: {}", e)) })?; - let mut version = version_result + let __version = version_result .and_then(|row| row.try_get::("", "version").ok()) .unwrap_or(0) as u32; - // Execute each migration block + // Execute each migration block within the same transaction #(#migration_blocks)* + // Commit the entire migration + __txn.commit().await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e)) + })?; + Ok::<(), ::vespertide::MigrationError>(()) } } From 2c036d1c27f2d76bdf522163f973fbd8ea4ce780 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 18:32:40 +0900 Subject: [PATCH 04/17] Fix revision command --- .../vespertide-cli/src/commands/revision.rs | 177 +++++++++--------- crates/vespertide-core/src/schema/column.rs | 89 +++++---- crates/vespertide-macro/src/lib.rs | 2 +- crates/vespertide-planner/src/validate.rs | 161 +++++++++++++--- .../src/sql/add_constraint.rs | 14 +- .../vespertide-query/src/sql/delete_column.rs | 11 +- 6 files changed, 282 insertions(+), 172 deletions(-) diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index db1d449..be00279 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -7,8 +7,8 @@ use colored::Colorize; use dialoguer::{Input, Select}; use serde_json::Value; use vespertide_config::FileFormat; -use vespertide_core::{MigrationAction, MigrationPlan}; -use vespertide_planner::{find_missing_fill_with, plan_next_migration}; +use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; +use vespertide_planner::{find_missing_fill_with, plan_next_migration, schema_from_plans}; use crate::utils::{ load_config, load_migrations, load_models, migration_filename_with_format_and_pattern, @@ -30,13 +30,8 @@ fn parse_fill_with_args(args: &[String]) -> HashMap<(String, String), String> { /// Format the type info string for display. /// Includes column type and default value hint if available. -fn format_type_info(column_type: Option<&String>, default_value: Option<&String>) -> String { - match (column_type, default_value) { - (Some(t), Some(d)) => format!(" ({}, default: {})", t, d), - (Some(t), None) => format!(" ({})", t), - (None, Some(d)) => format!(" (default: {})", d), - (None, None) => String::new(), - } +fn format_type_info(column_type: &str, default_value: &str) -> String { + format!(" ({}, default: {})", column_type, default_value) } /// Format a single fill_with item for display. @@ -80,8 +75,8 @@ fn print_fill_with_footer() { fn print_fill_with_item_and_get_prompt( table: &str, column: &str, - column_type: Option<&String>, - default_value: Option<&String>, + column_type: &str, + default_value: &str, action_type: &str, ) -> String { let type_info = format_type_info(column_type, default_value); @@ -109,10 +104,10 @@ fn wrap_if_spaces(value: String) -> String { /// Prompt the user for a fill_with value using dialoguer. /// This function wraps terminal I/O and cannot be unit tested without a real terminal. #[cfg(not(tarpaulin_include))] -fn prompt_fill_with_value(prompt: &str) -> Result { - let value = Input::new() +fn prompt_fill_with_value(prompt: &str, default: &str) -> Result { + let value: String = Input::new() .with_prompt(prompt) - .allow_empty(true) + .default(default.to_string()) .interact_text() .context("failed to read input")?; Ok(wrap_if_spaces(value)) @@ -142,7 +137,7 @@ fn collect_fill_with_values( enum_prompt_fn: E, ) -> Result<()> where - F: Fn(&str) -> Result, + F: Fn(&str, &str) -> Result, E: Fn(&str, &[String]) -> Result, { print_fill_with_header(); @@ -151,8 +146,8 @@ where let prompt = print_fill_with_item_and_get_prompt( &item.table, &item.column, - item.column_type.as_ref(), - item.default_value.as_ref(), + &item.column_type, + &item.default_value, item.action_type, ); @@ -160,8 +155,8 @@ where // Use selection UI for enum types enum_prompt_fn(&prompt, enum_values)? } else { - // Use text input for other types - prompt_fn(&prompt)? + // Use text input with default pre-filled + prompt_fn(&prompt, &item.default_value)? }; fill_values.insert((item.table.clone(), item.column.clone()), value); } @@ -210,14 +205,15 @@ fn apply_fill_with_to_plan( fn handle_missing_fill_with( plan: &mut MigrationPlan, fill_values: &mut HashMap<(String, String), String>, + current_schema: &[TableDef], prompt_fn: F, enum_prompt_fn: E, ) -> Result<()> where - F: Fn(&str) -> Result, + F: Fn(&str, &str) -> Result, E: Fn(&str, &[String]) -> Result, { - let missing = find_missing_fill_with(plan); + let missing = find_missing_fill_with(plan, current_schema); if !missing.is_empty() { collect_fill_with_values(&missing, fill_values, prompt_fn, enum_prompt_fn)?; @@ -246,6 +242,10 @@ pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> return Ok(()); } + // Reconstruct baseline schema for column type lookups + let baseline_schema = schema_from_plans(&applied_plans) + .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; + // Parse CLI fill_with arguments let mut fill_values = parse_fill_with_args(&fill_with_args); @@ -256,6 +256,7 @@ pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> handle_missing_fill_with( &mut plan, &mut fill_values, + &baseline_schema, prompt_fill_with_value, prompt_enum_value, )?; @@ -759,30 +760,14 @@ mod tests { #[test] fn test_format_type_info_with_type_and_default() { - let column_type = Some("integer".to_string()); - let default_value = Some("0".to_string()); - let result = format_type_info(column_type.as_ref(), default_value.as_ref()); + let result = format_type_info("integer", "0"); assert_eq!(result, " (integer, default: 0)"); } #[test] fn test_format_type_info_with_type_only() { - let column_type = Some("text".to_string()); - let result = format_type_info(column_type.as_ref(), None); - assert_eq!(result, " (text)"); - } - - #[test] - fn test_format_type_info_with_default_only() { - let default_value = Some("0".to_string()); - let result = format_type_info(None, default_value.as_ref()); - assert_eq!(result, " (default: 0)"); - } - - #[test] - fn test_format_type_info_with_none() { - let result = format_type_info(None, None); - assert_eq!(result, ""); + let result = format_type_info("text", "''"); + assert_eq!(result, " (text, default: '')"); } #[test] @@ -816,25 +801,20 @@ mod tests { #[test] fn test_print_fill_with_item_and_get_prompt() { // This function prints to stdout and returns the prompt string - let prompt = print_fill_with_item_and_get_prompt( - "users", - "email", - Some(&"text".to_string()), - Some(&"''".to_string()), - "AddColumn", - ); + let prompt = + print_fill_with_item_and_get_prompt("users", "email", "text", "''", "AddColumn"); assert!(prompt.contains("Enter fill value for")); assert!(prompt.contains("users")); assert!(prompt.contains("email")); } #[test] - fn test_print_fill_with_item_and_get_prompt_no_type() { + fn test_print_fill_with_item_and_get_prompt_no_default() { let prompt = print_fill_with_item_and_get_prompt( "orders", "status", - None, - None, + "text", + "''", "ModifyColumnNullable", ); assert!(prompt.contains("Enter fill value for")); @@ -844,13 +824,8 @@ mod tests { #[test] fn test_print_fill_with_item_and_get_prompt_with_default() { - let prompt = print_fill_with_item_and_get_prompt( - "users", - "age", - Some(&"integer".to_string()), - Some(&"0".to_string()), - "AddColumn", - ); + let prompt = + print_fill_with_item_and_get_prompt("users", "age", "integer", "0", "AddColumn"); assert!(prompt.contains("Enter fill value for")); assert!(prompt.contains("users")); assert!(prompt.contains("age")); @@ -882,16 +857,17 @@ mod tests { table: "users".to_string(), column: "email".to_string(), action_type: "AddColumn", - column_type: Some("text".to_string()), - default_value: Some("''".to_string()), + column_type: "text".to_string(), + default_value: "''".to_string(), enum_values: None, }]; let mut fill_values = HashMap::new(); // Mock prompt function that returns a fixed value - let mock_prompt = - |_prompt: &str| -> Result { Ok("'test@example.com'".to_string()) }; + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Ok("'test@example.com'".to_string()) + }; let result = collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); @@ -913,8 +889,8 @@ mod tests { table: "users".to_string(), column: "email".to_string(), action_type: "AddColumn", - column_type: Some("text".to_string()), - default_value: Some("''".to_string()), + column_type: "text".to_string(), + default_value: "''".to_string(), enum_values: None, }, FillWithRequired { @@ -922,8 +898,8 @@ mod tests { table: "orders".to_string(), column: "status".to_string(), action_type: "ModifyColumnNullable", - column_type: None, - default_value: None, + column_type: "text".to_string(), + default_value: "''".to_string(), enum_values: None, }, ]; @@ -932,7 +908,7 @@ mod tests { // Mock prompt function that returns different values based on call count let call_count = std::cell::RefCell::new(0); - let mock_prompt = |_prompt: &str| -> Result { + let mock_prompt = |_prompt: &str, _default: &str| -> Result { let mut count = call_count.borrow_mut(); *count += 1; match *count { @@ -964,7 +940,7 @@ mod tests { // This function should handle empty list gracefully (though it won't be called in practice) // But we can't test the header/footer without items since the function still prints them // So we test with a mock that would fail if called - let mock_prompt = |_prompt: &str| -> Result { + let mock_prompt = |_prompt: &str, _default: &str| -> Result { panic!("Should not be called for empty list"); }; @@ -985,16 +961,17 @@ mod tests { table: "users".to_string(), column: "email".to_string(), action_type: "AddColumn", - column_type: Some("text".to_string()), - default_value: Some("''".to_string()), + column_type: "text".to_string(), + default_value: "''".to_string(), enum_values: None, }]; let mut fill_values = HashMap::new(); // Mock prompt function that returns an error - let mock_prompt = - |_prompt: &str| -> Result { Err(anyhow::anyhow!("input cancelled")) }; + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Err(anyhow::anyhow!("input cancelled")) + }; let result = collect_fill_with_values(&missing, &mut fill_values, mock_prompt, mock_enum_prompt); @@ -1007,7 +984,7 @@ mod tests { // This test verifies that prompt_fill_with_value has the correct signature. // We cannot actually call it in tests because dialoguer::Input blocks waiting for terminal input. // The function is excluded from coverage with #[cfg_attr(coverage_nightly, coverage(off))]. - let _: fn(&str) -> Result = prompt_fill_with_value; + let _: fn(&str, &str) -> Result = prompt_fill_with_value; } #[test] @@ -1038,11 +1015,17 @@ mod tests { let mut fill_values = HashMap::new(); // Mock prompt function - let mock_prompt = - |_prompt: &str| -> Result { Ok("'test@example.com'".to_string()) }; + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Ok("'test@example.com'".to_string()) + }; - let result = - handle_missing_fill_with(&mut plan, &mut fill_values, mock_prompt, mock_enum_prompt); + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); assert!(result.is_ok()); // Verify fill_with was applied to the plan @@ -1089,12 +1072,17 @@ mod tests { let mut fill_values = HashMap::new(); // Mock prompt that should never be called - let mock_prompt = |_prompt: &str| -> Result { + let mock_prompt = |_prompt: &str, _default: &str| -> Result { panic!("Should not be called when no missing fill_with values"); }; - let result = - handle_missing_fill_with(&mut plan, &mut fill_values, mock_prompt, mock_enum_prompt); + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); assert!(result.is_ok()); assert!(fill_values.is_empty()); } @@ -1127,11 +1115,17 @@ mod tests { let mut fill_values = HashMap::new(); // Mock prompt that returns an error - let mock_prompt = - |_prompt: &str| -> Result { Err(anyhow::anyhow!("user cancelled")) }; + let mock_prompt = |_prompt: &str, _default: &str| -> Result { + Err(anyhow::anyhow!("user cancelled")) + }; - let result = - handle_missing_fill_with(&mut plan, &mut fill_values, mock_prompt, mock_enum_prompt); + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); assert!(result.is_err()); // Plan should not be modified on error @@ -1180,7 +1174,7 @@ mod tests { // Mock prompt that returns different values based on call count let call_count = std::cell::RefCell::new(0); - let mock_prompt = |_prompt: &str| -> Result { + let mock_prompt = |_prompt: &str, _default: &str| -> Result { let mut count = call_count.borrow_mut(); *count += 1; match *count { @@ -1190,8 +1184,13 @@ mod tests { } }; - let result = - handle_missing_fill_with(&mut plan, &mut fill_values, mock_prompt, mock_enum_prompt); + let result = handle_missing_fill_with( + &mut plan, + &mut fill_values, + &[], + mock_prompt, + mock_enum_prompt, + ); assert!(result.is_ok()); // Verify both actions were updated @@ -1219,8 +1218,8 @@ mod tests { table: "orders".to_string(), column: "status".to_string(), action_type: "AddColumn", - column_type: Some("enum".to_string()), - default_value: None, + column_type: "enum".to_string(), + default_value: "''".to_string(), enum_values: Some(vec![ "pending".to_string(), "confirmed".to_string(), @@ -1231,7 +1230,7 @@ mod tests { let mut fill_values = HashMap::new(); // Mock prompt function that should NOT be called for enum columns - let mock_prompt = |_prompt: &str| -> Result { + let mock_prompt = |_prompt: &str, _default: &str| -> Result { panic!("Should not be called for enum columns"); }; diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 2256626..72d6e58 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -120,7 +120,7 @@ impl ColumnType { /// Get the default fill value for this column type (for CLI prompts) /// Returns None if no sensible default exists for the type - pub fn default_fill_value(&self) -> Option<&'static str> { + pub fn default_fill_value(&self) -> &'static str { match self { ColumnType::Simple(ty) => ty.default_fill_value(), ColumnType::Complex(ty) => ty.default_fill_value(), @@ -220,15 +220,24 @@ impl SimpleColumnType { /// Get the default fill value for this type /// Returns None if no sensible default exists - pub fn default_fill_value(&self) -> Option<&'static str> { + pub fn default_fill_value(&self) -> &'static str { match self { SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt => { - Some("0") + "0" } - SimpleColumnType::Real | SimpleColumnType::DoublePrecision => Some("0.0"), - SimpleColumnType::Boolean => Some("false"), - SimpleColumnType::Text => Some("''"), - _ => None, + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => "0.0", + SimpleColumnType::Boolean => "false", + SimpleColumnType::Text => "''", + SimpleColumnType::Date => "'1970-01-01'", + SimpleColumnType::Time => "'00:00:00'", + SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => "CURRENT_TIMESTAMP", + SimpleColumnType::Interval => "'0'", + SimpleColumnType::Uuid => "'00000000-0000-0000-0000-000000000000'", + SimpleColumnType::Json => "'{}'", + SimpleColumnType::Bytea => "''", + SimpleColumnType::Inet | SimpleColumnType::Cidr => "'0.0.0.0'", + SimpleColumnType::Macaddr => "'00:00:00:00:00:00'", + SimpleColumnType::Xml => "''", } } } @@ -335,13 +344,13 @@ impl ComplexColumnType { } } - /// Get the default fill value for this type - /// Returns None if no sensible default exists - pub fn default_fill_value(&self) -> Option<&'static str> { + /// Get the default fill value for this type. + pub fn default_fill_value(&self) -> &'static str { match self { - ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => Some("''"), - ComplexColumnType::Numeric { .. } => Some("0"), - _ => None, + ComplexColumnType::Varchar { .. } | ComplexColumnType::Char { .. } => "''", + ComplexColumnType::Numeric { .. } => "0", + ComplexColumnType::Custom { .. } => "''", + ComplexColumnType::Enum { .. } => "''", } } } @@ -784,28 +793,28 @@ mod tests { // Tests for default_fill_value #[rstest] - #[case(SimpleColumnType::SmallInt, Some("0"))] - #[case(SimpleColumnType::Integer, Some("0"))] - #[case(SimpleColumnType::BigInt, Some("0"))] - #[case(SimpleColumnType::Real, Some("0.0"))] - #[case(SimpleColumnType::DoublePrecision, Some("0.0"))] - #[case(SimpleColumnType::Boolean, Some("false"))] - #[case(SimpleColumnType::Text, Some("''"))] - #[case(SimpleColumnType::Date, None)] - #[case(SimpleColumnType::Time, None)] - #[case(SimpleColumnType::Timestamp, None)] - #[case(SimpleColumnType::Timestamptz, None)] - #[case(SimpleColumnType::Interval, None)] - #[case(SimpleColumnType::Bytea, None)] - #[case(SimpleColumnType::Uuid, None)] - #[case(SimpleColumnType::Json, None)] - #[case(SimpleColumnType::Inet, None)] - #[case(SimpleColumnType::Cidr, None)] - #[case(SimpleColumnType::Macaddr, None)] - #[case(SimpleColumnType::Xml, None)] + #[case(SimpleColumnType::SmallInt, "0")] + #[case(SimpleColumnType::Integer, "0")] + #[case(SimpleColumnType::BigInt, "0")] + #[case(SimpleColumnType::Real, "0.0")] + #[case(SimpleColumnType::DoublePrecision, "0.0")] + #[case(SimpleColumnType::Boolean, "false")] + #[case(SimpleColumnType::Text, "''")] + #[case(SimpleColumnType::Date, "'1970-01-01'")] + #[case(SimpleColumnType::Time, "'00:00:00'")] + #[case(SimpleColumnType::Timestamp, "CURRENT_TIMESTAMP")] + #[case(SimpleColumnType::Timestamptz, "CURRENT_TIMESTAMP")] + #[case(SimpleColumnType::Interval, "'0'")] + #[case(SimpleColumnType::Bytea, "''")] + #[case(SimpleColumnType::Uuid, "'00000000-0000-0000-0000-000000000000'")] + #[case(SimpleColumnType::Json, "'{}'")] + #[case(SimpleColumnType::Inet, "'0.0.0.0'")] + #[case(SimpleColumnType::Cidr, "'0.0.0.0'")] + #[case(SimpleColumnType::Macaddr, "'00:00:00:00:00:00'")] + #[case(SimpleColumnType::Xml, "''")] fn test_simple_column_type_default_fill_value( #[case] column_type: SimpleColumnType, - #[case] expected: Option<&str>, + #[case] expected: &str, ) { assert_eq!(column_type.default_fill_value(), expected); } @@ -813,13 +822,13 @@ mod tests { #[test] fn test_complex_column_type_default_fill_value_varchar() { let ty = ComplexColumnType::Varchar { length: 255 }; - assert_eq!(ty.default_fill_value(), Some("''")); + assert_eq!(ty.default_fill_value(), "''"); } #[test] fn test_complex_column_type_default_fill_value_char() { let ty = ComplexColumnType::Char { length: 1 }; - assert_eq!(ty.default_fill_value(), Some("''")); + assert_eq!(ty.default_fill_value(), "''"); } #[test] @@ -828,7 +837,7 @@ mod tests { precision: 10, scale: 2, }; - assert_eq!(ty.default_fill_value(), Some("0")); + assert_eq!(ty.default_fill_value(), "0"); } #[test] @@ -836,7 +845,7 @@ mod tests { let ty = ComplexColumnType::Custom { custom_type: "MONEY".into(), }; - assert_eq!(ty.default_fill_value(), None); + assert_eq!(ty.default_fill_value(), "''"); } #[test] @@ -845,19 +854,19 @@ mod tests { name: "status".into(), values: EnumValues::String(vec!["active".into()]), }; - assert_eq!(ty.default_fill_value(), None); + assert_eq!(ty.default_fill_value(), "''"); } #[test] fn test_column_type_default_fill_value_simple() { let ty = ColumnType::Simple(SimpleColumnType::Integer); - assert_eq!(ty.default_fill_value(), Some("0")); + assert_eq!(ty.default_fill_value(), "0"); } #[test] fn test_column_type_default_fill_value_complex() { let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 100 }); - assert_eq!(ty.default_fill_value(), Some("''")); + assert_eq!(ty.default_fill_value(), "''"); } // Tests for enum_variant_names diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 14451c7..253be1b 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -11,7 +11,7 @@ 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::{build_plan_queries, DatabaseBackend}; +use vespertide_query::{DatabaseBackend, build_plan_queries}; struct MacroInput { pool: Expr, diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index e676f9b..ce67f0e 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -70,13 +70,14 @@ fn validate_table( { for col_name in columns { if let Some(column) = table.columns.iter().find(|c| c.name == *col_name) - && !column.r#type.supports_auto_increment() { - return Err(PlannerError::InvalidAutoIncrement( - table.name.clone(), - col_name.clone(), - format!("{:?}", column.r#type), - )); - } + && !column.r#type.supports_auto_increment() + { + return Err(PlannerError::InvalidAutoIncrement( + table.name.clone(), + col_name.clone(), + format!("{:?}", column.r#type), + )); + } } } } @@ -439,16 +440,22 @@ pub struct FillWithRequired { /// Type of action: "AddColumn" or "ModifyColumnNullable". pub action_type: &'static str, /// Column type (for display purposes). - pub column_type: Option, + pub column_type: String, /// Default fill value hint for this column type. - pub default_value: Option, + pub default_value: String, /// Enum values if the column is an enum type (for selection UI). pub enum_values: Option>, } /// Find all actions in a migration plan that require fill_with values. /// Returns a list of actions that need fill_with but don't have one. -pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec { +/// +/// `current_schema` is the baseline schema (from applied migrations) used to look up +/// column type info for `ModifyColumnNullable` actions. Pass an empty slice if unavailable. +pub fn find_missing_fill_with( + plan: &MigrationPlan, + current_schema: &[TableDef], +) -> Vec { let mut missing = Vec::new(); for (idx, action) in plan.actions.iter().enumerate() { @@ -465,8 +472,8 @@ pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec { table: table.clone(), column: column.name.clone(), action_type: "AddColumn", - column_type: Some(column.r#type.to_display_string()), - default_value: column.r#type.default_fill_value().map(String::from), + column_type: column.r#type.to_display_string(), + default_value: column.r#type.default_fill_value().to_string(), enum_values: column.r#type.enum_variant_names(), }); } @@ -478,15 +485,36 @@ pub fn find_missing_fill_with(plan: &MigrationPlan) -> Vec { fill_with, } => { // If changing from nullable to non-nullable, fill_with is required + // UNLESS the column already has a default value (which will be used) if !nullable && fill_with.is_none() { + // Look up column from the current schema + let col_def = current_schema + .iter() + .find(|t| t.name == *table) + .and_then(|t| t.columns.iter().find(|c| c.name == *column)); + + // If column has a default value, fill_with is not needed + if col_def.is_some_and(|c| c.default.is_some()) { + continue; + } + + let (col_type_str, default_val, enum_vals) = match col_def { + Some(c) => ( + c.r#type.to_display_string(), + c.r#type.default_fill_value().to_string(), + c.r#type.enum_variant_names(), + ), + None => (column.clone(), "''".to_string(), None), + }; + missing.push(FillWithRequired { action_index: idx, table: table.clone(), column: column.clone(), action_type: "ModifyColumnNullable", - column_type: None, - default_value: None, - enum_values: None, // We don't have column type info here + column_type: col_type_str, + default_value: default_val, + enum_values: enum_vals, }); } } @@ -503,8 +531,8 @@ mod tests { use rstest::rstest; use vespertide_core::schema::primary_key::{PrimaryKeyDef, PrimaryKeySyntax}; use vespertide_core::{ - ColumnDef, ColumnType, ComplexColumnType, EnumValues, NumValue, SimpleColumnType, - TableConstraint, + ColumnDef, ColumnType, ComplexColumnType, DefaultValue, EnumValues, NumValue, + SimpleColumnType, TableConstraint, }; fn col(name: &str, ty: ColumnType) -> ColumnDef { @@ -1640,12 +1668,12 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert_eq!(missing.len(), 1); assert_eq!(missing[0].table, "users"); assert_eq!(missing[0].column, "email"); assert_eq!(missing[0].action_type, "AddColumn"); - assert!(missing[0].column_type.is_some()); + assert!(!missing[0].column_type.is_empty()); } #[test] @@ -1671,7 +1699,7 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert!(missing.is_empty()); } @@ -1698,7 +1726,7 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert!(missing.is_empty()); } @@ -1725,7 +1753,7 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert!(missing.is_empty()); } @@ -1743,12 +1771,13 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert_eq!(missing.len(), 1); assert_eq!(missing[0].table, "users"); assert_eq!(missing[0].column, "email"); assert_eq!(missing[0].action_type, "ModifyColumnNullable"); - assert!(missing[0].column_type.is_none()); + // With no schema provided, falls back to column name as type display + assert_eq!(missing[0].column_type, "email"); } #[test] @@ -1765,7 +1794,7 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert!(missing.is_empty()); } @@ -1783,10 +1812,86 @@ mod tests { }], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert!(missing.is_empty()); } + #[test] + fn find_missing_fill_with_modify_nullable_to_not_null_with_column_default() { + // Column has a default value in the schema, so fill_with should NOT be required + let schema = vec![TableDef { + name: "users".into(), + columns: vec![ColumnDef { + name: "status".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: Some(DefaultValue::String("'active'".into())), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + description: None, + }]; + + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "status".into(), + nullable: false, + fill_with: None, + }], + }; + + let missing = find_missing_fill_with(&plan, &schema); + assert!( + missing.is_empty(), + "fill_with should not be required when column has a default value" + ); + } + + #[test] + fn find_missing_fill_with_modify_nullable_to_not_null_without_column_default() { + // Column has NO default value, so fill_with IS required + let schema = vec![TableDef { + name: "users".into(), + columns: vec![ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![], + description: None, + }]; + + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![MigrationAction::ModifyColumnNullable { + table: "users".into(), + column: "email".into(), + nullable: false, + fill_with: None, + }], + }; + + let missing = find_missing_fill_with(&plan, &schema); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].column, "email"); + } + #[test] fn find_missing_fill_with_multiple_actions() { let plan = MigrationPlan { @@ -1833,7 +1938,7 @@ mod tests { ], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert_eq!(missing.len(), 2); assert_eq!(missing[0].action_index, 0); assert_eq!(missing[0].table, "users"); @@ -1862,7 +1967,7 @@ mod tests { ], }; - let missing = find_missing_fill_with(&plan); + let missing = find_missing_fill_with(&plan, &[]); assert!(missing.is_empty()); } diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index 307bd44..c7b2afe 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -96,9 +96,10 @@ pub fn build_add_constraint( // a prior temp table rebuild in the same migration already recreated it. if *backend == DatabaseBackend::Sqlite && let Some(table_def) = current_schema.iter().find(|t| t.name == table) - && table_def.constraints.contains(constraint) { - return Ok(vec![]); - } + && table_def.constraints.contains(constraint) + { + return Ok(vec![]); + } // Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns} let index_name = super::helpers::build_unique_constraint_name(table, columns, name.as_deref()); @@ -219,9 +220,10 @@ pub fn build_add_constraint( // a prior temp table rebuild in the same migration already recreated it. if *backend == DatabaseBackend::Sqlite && let Some(table_def) = current_schema.iter().find(|t| t.name == table) - && table_def.constraints.contains(constraint) { - return Ok(vec![]); - } + && table_def.constraints.contains(constraint) + { + return Ok(vec![]); + } let index_name = vespertide_naming::build_index_name(table, columns, name.as_deref()); let mut idx = Index::create() .table(Alias::new(table)) diff --git a/crates/vespertide-query/src/sql/delete_column.rs b/crates/vespertide-query/src/sql/delete_column.rs index b94d239..26b83a0 100644 --- a/crates/vespertide-query/src/sql/delete_column.rs +++ b/crates/vespertide-query/src/sql/delete_column.rs @@ -34,14 +34,9 @@ pub fn build_delete_column( if let Some(col_def) = table_def.columns.iter().find(|c| c.name == column) && let ColumnType::Complex(vespertide_core::ComplexColumnType::Enum { .. }) = &col_def.r#type - { - return build_delete_column_sqlite_temp_table( - table, - column, - table_def, - column_type, - ); - } + { + return build_delete_column_sqlite_temp_table(table, column, table_def, column_type); + } // Handle constraints referencing the deleted column for constraint in &table_def.constraints { From 7624205df83e72ba3cf78fcdef58056872452ee1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 18:39:49 +0900 Subject: [PATCH 05/17] Fix lint --- crates/vespertide-query/src/sql/helpers.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 34eb27f..247bd6e 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -390,6 +390,7 @@ pub fn extract_check_clauses(constraints: &[TableConstraint]) -> Vec { /// Combines both: /// - Enum-based CHECK constraints (from column types) /// - Explicit CHECK constraints (from `TableConstraint::Check`) +/// /// Returns deduplicated union of both. pub fn collect_all_check_clauses( table: &str, From 51bd8c5b9cc5b194da7f47abf76846032c6347d6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 20:39:02 +0900 Subject: [PATCH 06/17] Rm if exists --- crates/vespertide-planner/src/validate.rs | 15 ++++++ .../src/sql/add_constraint.rs | 38 +-------------- .../vespertide-query/src/sql/delete_column.rs | 2 +- crates/vespertide-query/src/sql/helpers.rs | 4 +- crates/vespertide-query/src/sql/mod.rs | 48 +++++++++++++++++++ .../src/sql/modify_column_type.rs | 2 +- ...enum_types_enum_name_changed_postgres.snap | 2 +- ...dify_enum_types_enum_to_text_postgres.snap | 2 +- ...th_enum_type@delete_enum_column_MySql.snap | 5 ++ ...enum_type@delete_enum_column_Postgres.snap | 6 +++ ...h_enum_type@delete_enum_column_Sqlite.snap | 8 ++++ 11 files changed, 90 insertions(+), 42 deletions(-) create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_MySql.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Postgres.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Sqlite.snap diff --git a/crates/vespertide-planner/src/validate.rs b/crates/vespertide-planner/src/validate.rs index ce67f0e..6947ea0 100644 --- a/crates/vespertide-planner/src/validate.rs +++ b/crates/vespertide-planner/src/validate.rs @@ -2027,4 +2027,19 @@ mod tests { _ => panic!("Expected InvalidAutoIncrement error"), } } + + #[test] + fn validate_inline_primary_key_bool_does_not_check_auto_increment() { + // PrimaryKeySyntax::Bool(true) has no auto_increment field, so validation + // should pass even on a non-integer column. + let mut col_def = col("code", ColumnType::Simple(SimpleColumnType::Text)); + col_def.primary_key = Some(PrimaryKeySyntax::Bool(true)); + + let table_def = table("items", vec![col_def], vec![]); + let result = validate_table(&table_def, &std::collections::HashMap::new()); + assert!( + result.is_ok(), + "Bool primary key should not trigger auto_increment validation" + ); + } } diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index c7b2afe..b86888d 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -23,7 +23,7 @@ pub fn build_add_constraint( let table_def = current_schema.iter().find(|t| t.name == table).ok_or_else(|| QueryError::Other(format!("Table '{}' not found in current schema. SQLite requires current schema information to add constraints.", table)))?; // Create new constraints with the added primary key constraint - let mut new_constraints = table_def.constraints.clone(); + let mut new_constraints: Vec = table_def.constraints.clone(); new_constraints.push(constraint.clone()); let temp_table = format!("{}_temp", table); @@ -92,14 +92,6 @@ pub fn build_add_constraint( } } TableConstraint::Unique { name, columns } => { - // On SQLite, skip if this constraint already exists in the schema — - // a prior temp table rebuild in the same migration already recreated it. - if *backend == DatabaseBackend::Sqlite - && let Some(table_def) = current_schema.iter().find(|t| t.name == table) - && table_def.constraints.contains(constraint) - { - return Ok(vec![]); - } // Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns} let index_name = super::helpers::build_unique_constraint_name(table, columns, name.as_deref()); @@ -127,26 +119,8 @@ pub fn build_add_constraint( let table_def = current_schema.iter().find(|t| t.name == table).ok_or_else(|| QueryError::Other(format!("Table '{}' not found in current schema. SQLite requires current schema information to add constraints.", table)))?; // Create new constraints with the added foreign key constraint - // Dedup: check if this FK already exists (e.g. from inline normalization) let mut new_constraints = table_def.constraints.clone(); - let fk_already_exists = new_constraints.iter().any(|c| { - if let TableConstraint::ForeignKey { - columns: existing_cols, - ref_table: existing_ref, - ref_columns: existing_ref_cols, - .. - } = c - { - columns == existing_cols - && ref_table == existing_ref - && ref_columns == existing_ref_cols - } else { - false - } - }); - if !fk_already_exists { - new_constraints.push(constraint.clone()); - } + new_constraints.push(constraint.clone()); let temp_table = format!("{}_temp", table); @@ -216,14 +190,6 @@ pub fn build_add_constraint( } } TableConstraint::Index { name, columns } => { - // On SQLite, skip if this constraint already exists in the schema — - // a prior temp table rebuild in the same migration already recreated it. - if *backend == DatabaseBackend::Sqlite - && let Some(table_def) = current_schema.iter().find(|t| t.name == table) - && table_def.constraints.contains(constraint) - { - return Ok(vec![]); - } let index_name = vespertide_naming::build_index_name(table, columns, name.as_deref()); let mut idx = Index::create() .table(Alias::new(table)) diff --git a/crates/vespertide-query/src/sql/delete_column.rs b/crates/vespertide-query/src/sql/delete_column.rs index 26b83a0..be00c18 100644 --- a/crates/vespertide-query/src/sql/delete_column.rs +++ b/crates/vespertide-query/src/sql/delete_column.rs @@ -277,7 +277,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 \"users_status\"")); + assert!(drop_type_sql.contains("DROP TYPE \"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 247bd6e..705fbfc 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -319,8 +319,8 @@ pub fn build_drop_enum_type_sql( // 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); + // PostgreSQL: DROP TYPE {table}_{name} + let pg_sql = format!("DROP TYPE \"{}\"", type_name); // MySQL/SQLite: No action needed Some(super::types::RawSql::per_backend( diff --git a/crates/vespertide-query/src/sql/mod.rs b/crates/vespertide-query/src/sql/mod.rs index cdac0e3..b67528b 100644 --- a/crates/vespertide-query/src/sql/mod.rs +++ b/crates/vespertide-query/src/sql/mod.rs @@ -1388,4 +1388,52 @@ mod tests { assert_snapshot!(sql); }); } + + #[rstest] + #[case::delete_enum_column_postgres(DatabaseBackend::Postgres)] + #[case::delete_enum_column_mysql(DatabaseBackend::MySql)] + #[case::delete_enum_column_sqlite(DatabaseBackend::Sqlite)] + fn test_delete_column_with_enum_type(#[case] backend: DatabaseBackend) { + // Deleting a column with an enum type — SQLite uses temp table approach, + // Postgres drops the enum type, MySQL uses simple DROP COLUMN. + let action = MigrationAction::DeleteColumn { + table: "orders".into(), + column: "status".into(), + }; + let schema = vec![TableDef { + name: "orders".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + ColumnDef { + name: "status".into(), + r#type: ColumnType::Complex(vespertide_core::ComplexColumnType::Enum { + name: "order_status".into(), + values: vespertide_core::EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + ]), + }), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + let result = build_action_queries(&backend, &action, &schema).unwrap(); + let sql = result + .iter() + .map(|q| q.build(backend)) + .collect::>() + .join(";\n"); + + with_settings!({ snapshot_suffix => format!("delete_enum_column_{:?}", backend) }, { + assert_snapshot!(sql); + }); + } } diff --git a/crates/vespertide-query/src/sql/modify_column_type.rs b/crates/vespertide-query/src/sql/modify_column_type.rs index f9e838f..7050b0b 100644 --- a/crates/vespertide-query/src/sql/modify_column_type.rs +++ b/crates/vespertide-query/src/sql/modify_column_type.rs @@ -256,7 +256,7 @@ pub fn build_modify_column_type( // 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_type_name), + format!("DROP TYPE \"{}\"", old_type_name), String::new(), String::new(), ))); 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 523a55b..a3e7af9 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 @@ -4,4 +4,4 @@ expression: sql --- 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" +DROP TYPE "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_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 56ff680..20b912d 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 "users_user_status" +DROP TYPE "users_user_status" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_MySql.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_MySql.snap new file mode 100644 index 0000000..f282d7b --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_MySql.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/mod.rs +expression: sql +--- +ALTER TABLE `orders` DROP COLUMN `status`; diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Postgres.snap new file mode 100644 index 0000000..169b85e --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Postgres.snap @@ -0,0 +1,6 @@ +--- +source: crates/vespertide-query/src/sql/mod.rs +expression: sql +--- +ALTER TABLE "orders" DROP COLUMN "status"; +DROP TYPE "orders_order_status" diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Sqlite.snap new file mode 100644 index 0000000..8868867 --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__delete_column_with_enum_type@delete_enum_column_Sqlite.snap @@ -0,0 +1,8 @@ +--- +source: crates/vespertide-query/src/sql/mod.rs +expression: sql +--- +CREATE TABLE "orders_temp" ( "id" integer ); +INSERT INTO "orders_temp" ("id") SELECT "id" FROM "orders"; +DROP TABLE "orders"; +ALTER TABLE "orders_temp" RENAME TO "orders"; From dc3ce13fbe09821f4464aee9d4a735c398eb5905 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 20:50:51 +0900 Subject: [PATCH 07/17] Add default issue --- crates/vespertide-query/src/sql/helpers.rs | 11 +++++ crates/vespertide-query/src/sql/mod.rs | 47 +++++++++++++++++++ ...fault@create_table_func_default_MySql.snap | 5 ++ ...lt@create_table_func_default_Postgres.snap | 5 ++ ...ault@create_table_func_default_Sqlite.snap | 5 ++ 5 files changed, 73 insertions(+) create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_MySql.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Postgres.snap create mode 100644 crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Sqlite.snap diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 705fbfc..6eba576 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -264,6 +264,17 @@ pub fn build_sea_column_def_with_table( converted }; + // SQLite requires DEFAULT (expr) for expressions containing function calls. + // Wrapping in parentheses is always safe for all backends. + let final_default = if *backend == DatabaseBackend::Sqlite + && final_default.contains('(') + && !final_default.starts_with('(') + { + format!("({})", final_default) + } else { + final_default + }; + col.default(Into::::into(sea_query::Expr::cust( final_default, ))); diff --git a/crates/vespertide-query/src/sql/mod.rs b/crates/vespertide-query/src/sql/mod.rs index b67528b..327fec1 100644 --- a/crates/vespertide-query/src/sql/mod.rs +++ b/crates/vespertide-query/src/sql/mod.rs @@ -1389,6 +1389,53 @@ mod tests { }); } + #[rstest] + #[case::create_table_func_default_postgres(DatabaseBackend::Postgres)] + #[case::create_table_func_default_mysql(DatabaseBackend::MySql)] + #[case::create_table_func_default_sqlite(DatabaseBackend::Sqlite)] + fn test_create_table_with_function_default(#[case] backend: DatabaseBackend) { + // SQLite requires DEFAULT (expr) for function-call defaults. + // This test ensures parentheses are added for SQLite. + let action = MigrationAction::CreateTable { + table: "users".into(), + columns: vec![ + ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: Some("gen_random_uuid()".into()), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "created_at".into(), + r#type: ColumnType::Simple(SimpleColumnType::Timestamptz), + nullable: false, + default: Some("now()".into()), + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }; + let result = build_action_queries(&backend, &action, &[]).unwrap(); + let sql = result + .iter() + .map(|q| q.build(backend)) + .collect::>() + .join(";\n"); + + with_settings!({ snapshot_suffix => format!("create_table_func_default_{:?}", backend) }, { + assert_snapshot!(sql); + }); + } + #[rstest] #[case::delete_enum_column_postgres(DatabaseBackend::Postgres)] #[case::delete_enum_column_mysql(DatabaseBackend::MySql)] diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_MySql.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_MySql.snap new file mode 100644 index 0000000..1ab9173 --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_MySql.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/mod.rs +expression: sql +--- +CREATE TABLE `users` ( `id` binary(16) NOT NULL DEFAULT (UUID()), `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ) diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Postgres.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Postgres.snap new file mode 100644 index 0000000..3b3dcce --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Postgres.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/mod.rs +expression: sql +--- +CREATE TABLE "users" ( "id" uuid NOT NULL DEFAULT gen_random_uuid(), "created_at" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP ) diff --git a/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Sqlite.snap b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Sqlite.snap new file mode 100644 index 0000000..3b2f875 --- /dev/null +++ b/crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__tests__create_table_with_function_default@create_table_func_default_Sqlite.snap @@ -0,0 +1,5 @@ +--- +source: crates/vespertide-query/src/sql/mod.rs +expression: sql +--- +CREATE TABLE "users" ( "id" uuid_text NOT NULL DEFAULT (lower(hex(randomblob(16)))), "created_at" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP ) From 82ba69e23a55ed8921c961927d26b1e67b7b1b16 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 21:51:46 +0900 Subject: [PATCH 08/17] Add verbose option --- crates/vespertide-macro/src/lib.rs | 199 ++++++++++++++++++++++------- examples/app/src/main.rs | 2 +- 2 files changed, 154 insertions(+), 47 deletions(-) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 253be1b..fbfef84 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -11,17 +11,19 @@ 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}; +use vespertide_query::{build_plan_queries, DatabaseBackend}; struct MacroInput { pool: Expr, version_table: Option, + verbose: bool, } impl Parse for MacroInput { fn parse(input: ParseStream) -> syn::Result { let pool = input.parse()?; let mut version_table = None; + let mut verbose = false; while !input.is_empty() { input.parse::()?; @@ -34,6 +36,8 @@ impl Parse for MacroInput { input.parse::()?; let value: syn::LitStr = input.parse()?; version_table = Some(value.value()); + } else if key == "verbose" { + verbose = true; } else { return Err(syn::Error::new( key.span(), @@ -45,15 +49,24 @@ impl Parse for MacroInput { Ok(MacroInput { pool, version_table, + verbose, }) } } -/// Build a migration block for a single migration version. -/// Returns the generated code block and updates the baseline schema. -pub(crate) fn build_migration_block( +/// Build a migration block with optional verbose logging. +pub(crate) fn build_migration_block_verbose( migration: &vespertide_core::MigrationPlan, baseline_schema: &mut Vec, + verbose: bool, +) -> Result { + build_migration_block_inner(migration, baseline_schema, verbose) +} + +fn build_migration_block_inner( + migration: &vespertide_core::MigrationPlan, + baseline_schema: &mut Vec, + verbose: bool, ) -> Result { let version = migration.version; @@ -70,63 +83,154 @@ pub(crate) fn build_migration_block( let _ = apply_action(baseline_schema, action); } - // Pre-generate SQL for all backends at compile time - // Each query may produce multiple SQL statements, so we flatten them - let mut pg_sqls = Vec::new(); - let mut mysql_sqls = Vec::new(); - let mut sqlite_sqls = Vec::new(); + // Generate version guard and SQL execution block + let version_str = format!("v{}", version); + let comment_str = migration.comment.as_deref().unwrap_or("").to_string(); + + let block = if verbose { + // Verbose mode: preserve per-action grouping with action descriptions + let total_sql_count: usize = queries + .iter() + .map(|q| q.postgres.len().max(q.mysql.len()).max(q.sqlite.len())) + .sum(); + let total_sql_count_lit = total_sql_count; + + let mut action_blocks = Vec::new(); + let mut global_idx: usize = 0; + + for (action_idx, q) in queries.iter().enumerate() { + let action_desc = format!("{}", q.action); + let action_num = action_idx + 1; + let total_actions = queries.len(); + + let pg: Vec = q + .postgres + .iter() + .map(|s| s.build(DatabaseBackend::Postgres)) + .collect(); + let mysql: Vec = q + .mysql + .iter() + .map(|s| s.build(DatabaseBackend::MySql)) + .collect(); + let sqlite: Vec = q + .sqlite + .iter() + .map(|s| s.build(DatabaseBackend::Sqlite)) + .collect(); + + // Build per-SQL execution with global index + let sql_count = pg.len().max(mysql.len()).max(sqlite.len()); + let mut sql_exec_blocks = Vec::new(); + + for i in 0..sql_count { + let idx = global_idx + i + 1; + let pg_sql = pg.get(i).cloned().unwrap_or_default(); + let mysql_sql = mysql.get(i).cloned().unwrap_or_default(); + let sqlite_sql = sqlite.get(i).cloned().unwrap_or_default(); + + sql_exec_blocks.push(quote! { + { + let sql: &str = match backend { + sea_orm::DatabaseBackend::Postgres => #pg_sql, + sea_orm::DatabaseBackend::MySql => #mysql_sql, + sea_orm::DatabaseBackend::Sqlite => #sqlite_sql, + _ => #pg_sql, + }; + if !sql.is_empty() { + eprintln!("[vespertide] [{}/{}] {}", #idx, #total_sql_count_lit, sql); + let stmt = sea_orm::Statement::from_string(backend, sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) + })?; + } + } + }); + } + global_idx += sql_count; - for q in &queries { - for stmt in &q.postgres { - pg_sqls.push(stmt.build(DatabaseBackend::Postgres)); + action_blocks.push(quote! { + eprintln!("[vespertide] Action {}/{}: {}", #action_num, #total_actions, #action_desc); + #(#sql_exec_blocks)* + }); } - for stmt in &q.mysql { - mysql_sqls.push(stmt.build(DatabaseBackend::MySql)); + + quote! { + if __version < #version { + eprintln!("[vespertide] Applying migration {} ({})", #version_str, #comment_str); + #(#action_blocks)* + + let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", __version_table, #version); + let stmt = sea_orm::Statement::from_string(backend, insert_sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) + })?; + + eprintln!("[vespertide] Migration {} applied successfully", #version_str); + } } - for stmt in &q.sqlite { - sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite)); + } else { + // Non-verbose: flatten all SQL into one array (minimal overhead) + let mut pg_sqls = Vec::new(); + let mut mysql_sqls = Vec::new(); + let mut sqlite_sqls = Vec::new(); + + for q in &queries { + for stmt in &q.postgres { + pg_sqls.push(stmt.build(DatabaseBackend::Postgres)); + } + for stmt in &q.mysql { + mysql_sqls.push(stmt.build(DatabaseBackend::MySql)); + } + for stmt in &q.sqlite { + sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite)); + } } - } - // Generate version guard and SQL execution block - let block = quote! { - if __version < #version { - // Select SQL statements based on backend - let sqls: &[&str] = match backend { - sea_orm::DatabaseBackend::Postgres => &[#(#pg_sqls),*], - sea_orm::DatabaseBackend::MySql => &[#(#mysql_sqls),*], - sea_orm::DatabaseBackend::Sqlite => &[#(#sqlite_sqls),*], - _ => &[#(#pg_sqls),*], // Fallback to PostgreSQL syntax for unknown backends - }; - - // Execute SQL statements - for sql in sqls { - if !sql.is_empty() { - let stmt = sea_orm::Statement::from_string(backend, *sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) - })?; + quote! { + if __version < #version { + let sqls: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => &[#(#pg_sqls),*], + sea_orm::DatabaseBackend::MySql => &[#(#mysql_sqls),*], + sea_orm::DatabaseBackend::Sqlite => &[#(#sqlite_sqls),*], + _ => &[#(#pg_sqls),*], + }; + + for sql in sqls { + if !sql.is_empty() { + let stmt = sea_orm::Statement::from_string(backend, *sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) + })?; + } } - } - // Insert version record for this migration - let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", __version_table, #version); - let stmt = sea_orm::Statement::from_string(backend, insert_sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) - })?; + let insert_sql = format!("INSERT INTO {q}{}{q} (version) VALUES ({})", __version_table, #version); + let stmt = sea_orm::Statement::from_string(backend, insert_sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) + })?; + } } }; Ok(block) } -/// Generate the final async migration block with all migrations. -pub(crate) fn generate_migration_code( +fn generate_migration_code_inner( pool: &Expr, version_table: &str, migration_blocks: Vec, + verbose: bool, ) -> proc_macro2::TokenStream { + let verbose_current_version = if verbose { + quote! { + eprintln!("[vespertide] Current database version: {}", __version); + } + } else { + quote! {} + }; + quote! { async { use sea_orm::{ConnectionTrait, TransactionTrait}; @@ -163,6 +267,8 @@ pub(crate) fn generate_migration_code( .and_then(|row| row.try_get::("", "version").ok()) .unwrap_or(0) as u32; + #verbose_current_version + // Execute each migration block within the same transaction #(#migration_blocks)* @@ -185,6 +291,7 @@ pub(crate) fn vespertide_migration_impl( Err(e) => return e.to_compile_error(), }; let pool = &input.pool; + let verbose = input.verbose; // Get project root from CARGO_MANIFEST_DIR (same as load_migrations_at_compile_time) let project_root = match env::var("CARGO_MANIFEST_DIR") { @@ -243,7 +350,7 @@ pub(crate) fn vespertide_migration_impl( for migration in &migrations { // Apply prefix to migration table names let prefixed_migration = migration.clone().with_prefix(prefix); - match build_migration_block(&prefixed_migration, &mut baseline_schema) { + match build_migration_block_verbose(&prefixed_migration, &mut baseline_schema, verbose) { Ok(block) => migration_blocks.push(block), Err(e) => { return syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error(); @@ -251,7 +358,7 @@ pub(crate) fn vespertide_migration_impl( } } - generate_migration_code(pool, &version_table, migration_blocks) + generate_migration_code_inner(pool, &version_table, migration_blocks, verbose) } /// Zero-runtime migration entry point. diff --git a/examples/app/src/main.rs b/examples/app/src/main.rs index bc9619b..bd88e0f 100644 --- a/examples/app/src/main.rs +++ b/examples/app/src/main.rs @@ -22,7 +22,7 @@ async fn main() -> Result<()> { println!("Successfully connected to SQLite database!"); - vespertide::vespertide_migration!(db, version_table = "vespertide_version").await?; + vespertide::vespertide_migration!(db, version_table = "vespertide_version", verbose).await?; Ok(()) } From 1aa4be1f85d26850d417e714390c1a5f6a224c6a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 22:57:40 +0900 Subject: [PATCH 09/17] Fix sqlite fk issue --- crates/vespertide-query/src/builder.rs | 497 +++++++++++++++++- ...d_index_pair@add_col_pair_fk_ix_mysql.snap | 12 + ...ndex_pair@add_col_pair_fk_ix_postgres.snap | 12 + ..._index_pair@add_col_pair_fk_ix_sqlite.snap | 15 + ...d_index_pair@add_col_pair_ix_fk_mysql.snap | 12 + ...ndex_pair@add_col_pair_ix_fk_postgres.snap | 12 + ..._index_pair@add_col_pair_ix_fk_sqlite.snap | 16 + ..._unique_pair@add_col_pair_fk_uq_mysql.snap | 12 + ...ique_pair@add_col_pair_fk_uq_postgres.snap | 12 + ...unique_pair@add_col_pair_fk_uq_sqlite.snap | 15 + ..._unique_pair@add_col_pair_uq_fk_mysql.snap | 12 + ...ique_pair@add_col_pair_uq_fk_postgres.snap | 12 + ...unique_pair@add_col_pair_uq_fk_sqlite.snap | 16 + ...plicate_fk_in_temp_table@dup_fk_mysql.snap | 12 + ...cate_fk_in_temp_table@dup_fk_postgres.snap | 12 + ...licate_fk_in_temp_table@dup_fk_sqlite.snap | 22 + ..._all_orderings@add_col_fk_ix_uq_mysql.snap | 15 + ...l_orderings@add_col_fk_ix_uq_postgres.snap | 15 + ...all_orderings@add_col_fk_ix_uq_sqlite.snap | 18 + ..._all_orderings@add_col_fk_uq_ix_mysql.snap | 15 + ...l_orderings@add_col_fk_uq_ix_postgres.snap | 15 + ...all_orderings@add_col_fk_uq_ix_sqlite.snap | 18 + ..._all_orderings@add_col_ix_fk_uq_mysql.snap | 15 + ...l_orderings@add_col_ix_fk_uq_postgres.snap | 15 + ...all_orderings@add_col_ix_fk_uq_sqlite.snap | 19 + ..._all_orderings@add_col_ix_uq_fk_mysql.snap | 15 + ...l_orderings@add_col_ix_uq_fk_postgres.snap | 15 + ...all_orderings@add_col_ix_uq_fk_sqlite.snap | 20 + ..._all_orderings@add_col_uq_fk_ix_mysql.snap | 15 + ...l_orderings@add_col_uq_fk_ix_postgres.snap | 15 + ...all_orderings@add_col_uq_fk_ix_sqlite.snap | 19 + ..._all_orderings@add_col_uq_ix_fk_mysql.snap | 15 + ...l_orderings@add_col_uq_ix_fk_postgres.snap | 15 + ...all_orderings@add_col_uq_ix_fk_sqlite.snap | 20 + ...n_all_orderings@rm_col_fk_ix_uq_mysql.snap | 15 + ...ll_orderings@rm_col_fk_ix_uq_postgres.snap | 15 + ..._all_orderings@rm_col_fk_ix_uq_sqlite.snap | 23 + ...n_all_orderings@rm_col_fk_uq_ix_mysql.snap | 15 + ...ll_orderings@rm_col_fk_uq_ix_postgres.snap | 15 + ..._all_orderings@rm_col_fk_uq_ix_sqlite.snap | 24 + ...n_all_orderings@rm_col_ix_fk_uq_mysql.snap | 15 + ...ll_orderings@rm_col_ix_fk_uq_postgres.snap | 15 + ..._all_orderings@rm_col_ix_fk_uq_sqlite.snap | 22 + ...n_all_orderings@rm_col_ix_uq_fk_mysql.snap | 15 + ...ll_orderings@rm_col_ix_uq_fk_postgres.snap | 15 + ..._all_orderings@rm_col_ix_uq_fk_sqlite.snap | 21 + ...n_all_orderings@rm_col_uq_fk_ix_mysql.snap | 15 + ...ll_orderings@rm_col_uq_fk_ix_postgres.snap | 15 + ..._all_orderings@rm_col_uq_fk_ix_sqlite.snap | 23 + ...n_all_orderings@rm_col_uq_ix_fk_mysql.snap | 15 + ...ll_orderings@rm_col_uq_ix_fk_postgres.snap | 15 + ..._all_orderings@rm_col_uq_ix_fk_sqlite.snap | 22 + crates/vespertide-query/src/sql/add_column.rs | 2 +- .../src/sql/add_constraint.rs | 39 +- .../vespertide-query/src/sql/delete_column.rs | 2 +- crates/vespertide-query/src/sql/helpers.rs | 9 + crates/vespertide-query/src/sql/mod.rs | 18 +- .../src/sql/modify_column_default.rs | 1 + .../src/sql/modify_column_nullable.rs | 1 + .../src/sql/modify_column_type.rs | 2 +- .../src/sql/remove_constraint.rs | 11 +- 61 files changed, 1373 insertions(+), 22 deletions(-) create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_sqlite.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_mysql.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_postgres.snap create mode 100644 crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_sqlite.snap diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 4b364be..206a97e 100644 --- a/crates/vespertide-query/src/builder.rs +++ b/crates/vespertide-query/src/builder.rs @@ -1,9 +1,10 @@ use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; use vespertide_planner::apply_action; -use crate::DatabaseBackend; use crate::error::QueryError; -use crate::sql::{BuiltQuery, build_action_queries}; +use crate::sql::build_action_queries_with_pending; +use crate::sql::BuiltQuery; +use crate::DatabaseBackend; pub struct PlanQueries { pub action: MigrationAction, @@ -20,14 +21,61 @@ pub fn build_plan_queries( // Clone the schema so we can mutate it as we apply actions let mut evolving_schema = current_schema.to_vec(); - for action in &plan.actions { + for (i, action) in plan.actions.iter().enumerate() { + // For SQLite: collect pending AddConstraint Index/Unique actions for the same table. + // These constraints may exist in the logical schema (from AddColumn normalization) + // but haven't been physically created as DB indexes yet. + // Without this, a temp table rebuild would recreate these indexes prematurely, + // causing "index already exists" errors when their AddConstraint actions run later. + let pending_constraints: Vec = + if let MigrationAction::AddConstraint { table, .. } = action { + plan.actions[i + 1..] + .iter() + .filter_map(|a| { + if let MigrationAction::AddConstraint { + table: t, + constraint, + } = a + { + if t == table + && matches!( + constraint, + vespertide_core::TableConstraint::Index { .. } + | vespertide_core::TableConstraint::Unique { .. } + ) + { + Some(constraint.clone()) + } else { + None + } + } else { + None + } + }) + .collect() + } else { + vec![] + }; + // Build queries with the current state of the schema - let postgres_queries = - build_action_queries(&DatabaseBackend::Postgres, action, &evolving_schema)?; - let mysql_queries = - build_action_queries(&DatabaseBackend::MySql, action, &evolving_schema)?; - let sqlite_queries = - build_action_queries(&DatabaseBackend::Sqlite, action, &evolving_schema)?; + let postgres_queries = build_action_queries_with_pending( + &DatabaseBackend::Postgres, + action, + &evolving_schema, + &pending_constraints, + )?; + let mysql_queries = build_action_queries_with_pending( + &DatabaseBackend::MySql, + action, + &evolving_schema, + &pending_constraints, + )?; + let sqlite_queries = build_action_queries_with_pending( + &DatabaseBackend::Sqlite, + action, + &evolving_schema, + &pending_constraints, + )?; queries.push(PlanQueries { action: action.clone(), postgres: postgres_queries, @@ -277,4 +325,435 @@ mod tests { .join(";\n"); assert!(sql2_mysql.contains("`posts`")); } + + // ── Helpers for constraint migration tests ────────────────────────── + + use vespertide_core::{ReferenceAction, TableConstraint}; + + fn fk_constraint() -> TableConstraint { + TableConstraint::ForeignKey { + name: None, + columns: vec!["category_id".into()], + ref_table: "category".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + } + } + + fn unique_constraint() -> TableConstraint { + TableConstraint::Unique { + name: None, + columns: vec!["category_id".into()], + } + } + + fn index_constraint() -> TableConstraint { + TableConstraint::Index { + name: None, + columns: vec!["category_id".into()], + } + } + + /// Build a plan that adds a column then adds constraints in the given order. + fn plan_add_column_with_constraints(order: &[TableConstraint]) -> MigrationPlan { + let mut actions: Vec = vec![MigrationAction::AddColumn { + table: "product".into(), + column: Box::new(col( + "category_id", + ColumnType::Simple(SimpleColumnType::BigInt), + )), + fill_with: None, + }]; + for c in order { + actions.push(MigrationAction::AddConstraint { + table: "product".into(), + constraint: c.clone(), + }); + } + MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions, + } + } + + /// Build a plan that removes constraints in the given order then drops the column. + fn plan_remove_constraints_then_drop(order: &[TableConstraint]) -> MigrationPlan { + let mut actions: Vec = Vec::new(); + for c in order { + actions.push(MigrationAction::RemoveConstraint { + table: "product".into(), + constraint: c.clone(), + }); + } + actions.push(MigrationAction::DeleteColumn { + table: "product".into(), + column: "category_id".into(), + }); + MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions, + } + } + + /// Schema with an existing table that has NO constraints on category_id (for add tests). + fn base_schema_no_constraints() -> Vec { + vec![TableDef { + name: "product".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![], + }] + } + + /// Schema with an existing table that HAS FK + Unique + Index on category_id (for remove tests). + fn base_schema_with_all_constraints() -> Vec { + vec![TableDef { + name: "product".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("category_id", ColumnType::Simple(SimpleColumnType::BigInt)), + ], + constraints: vec![fk_constraint(), unique_constraint(), index_constraint()], + }] + } + + /// Collect ALL SQL statements from a plan result for a given backend. + fn collect_all_sql(result: &[PlanQueries], backend: DatabaseBackend) -> String { + result + .iter() + .enumerate() + .map(|(i, pq)| { + let queries = match backend { + DatabaseBackend::Postgres => &pq.postgres, + DatabaseBackend::MySql => &pq.mysql, + DatabaseBackend::Sqlite => &pq.sqlite, + }; + let sql = build_sql_snapshot(queries, backend); + format!("-- Action {}: {:?}\n{}", i, pq.action, sql) + }) + .collect::>() + .join("\n\n") + } + + /// Assert no duplicate CREATE INDEX / CREATE UNIQUE INDEX within a single + /// action's SQLite output. Cross-action duplicates are allowed because a + /// temp table rebuild (DROP + RENAME) legitimately destroys and recreates + /// indexes that a prior action already created. + fn assert_no_duplicate_indexes_per_action(result: &[PlanQueries]) { + for (i, pq) in result.iter().enumerate() { + let stmts: Vec = pq + .sqlite + .iter() + .map(|q| q.build(DatabaseBackend::Sqlite)) + .collect(); + + let index_stmts: Vec<&String> = stmts + .iter() + .filter(|s| s.contains("CREATE INDEX") || s.contains("CREATE UNIQUE INDEX")) + .collect(); + + let mut seen = std::collections::HashSet::new(); + for stmt in &index_stmts { + assert!( + seen.insert(stmt.as_str()), + "Duplicate index within action {} ({:?}):\n {}\nAll index statements in this action:\n{}", + i, + pq.action, + stmt, + index_stmts + .iter() + .map(|s| format!(" {}", s)) + .collect::>() + .join("\n") + ); + } + } + } + + /// Assert that no AddConstraint Index/Unique action produces an index that + /// was already recreated by a preceding temp-table rebuild within the same plan. + /// This catches the original bug: FK temp-table rebuild creating an index that + /// a later AddConstraint INDEX also creates (without DROP TABLE in between). + fn assert_no_orphan_duplicate_indexes(result: &[PlanQueries]) { + // Track indexes that exist after each action. + // A DROP TABLE resets the set; CREATE INDEX adds to it. + let mut live_indexes: std::collections::HashSet = std::collections::HashSet::new(); + + for pq in result { + let stmts: Vec = pq + .sqlite + .iter() + .map(|q| q.build(DatabaseBackend::Sqlite)) + .collect(); + + // If this action does a DROP TABLE, all indexes are destroyed + if stmts.iter().any(|s| s.starts_with("DROP TABLE")) { + live_indexes.clear(); + } + + for stmt in &stmts { + if stmt.contains("CREATE INDEX") || stmt.contains("CREATE UNIQUE INDEX") { + assert!( + live_indexes.insert(stmt.clone()), + "Index would already exist when action {:?} tries to create it:\n {}\nCurrently live indexes:\n{}", + pq.action, + stmt, + live_indexes + .iter() + .map(|s| format!(" {}", s)) + .collect::>() + .join("\n") + ); + } + } + + // DROP INDEX removes from live set + for stmt in &stmts { + if stmt.starts_with("DROP INDEX") { + live_indexes.retain(|s| { + // Extract index name from DROP INDEX "name" + let drop_name = stmt + .strip_prefix("DROP INDEX \"") + .and_then(|s| s.strip_suffix('"')); + if let Some(name) = drop_name { + !s.contains(&format!("\"{}\"", name)) + } else { + true + } + }); + } + } + } + } + + // ── Add column + FK/Unique/Index – all orderings ───────────────────── + + #[rstest] + #[case::fk_unique_index("fk_uq_ix", &[fk_constraint(), unique_constraint(), index_constraint()])] + #[case::fk_index_unique("fk_ix_uq", &[fk_constraint(), index_constraint(), unique_constraint()])] + #[case::unique_fk_index("uq_fk_ix", &[unique_constraint(), fk_constraint(), index_constraint()])] + #[case::unique_index_fk("uq_ix_fk", &[unique_constraint(), index_constraint(), fk_constraint()])] + #[case::index_fk_unique("ix_fk_uq", &[index_constraint(), fk_constraint(), unique_constraint()])] + #[case::index_unique_fk("ix_uq_fk", &[index_constraint(), unique_constraint(), fk_constraint()])] + fn test_add_column_with_fk_unique_index_all_orderings( + #[case] title: &str, + #[case] order: &[TableConstraint], + ) { + let plan = plan_add_column_with_constraints(order); + let schema = base_schema_no_constraints(); + let result = build_plan_queries(&plan, &schema).unwrap(); + + // Core invariant: no conflicting duplicate indexes in SQLite + assert_no_duplicate_indexes_per_action(&result); + assert_no_orphan_duplicate_indexes(&result); + + // Snapshot per backend + for (backend, label) in [ + (DatabaseBackend::Postgres, "postgres"), + (DatabaseBackend::MySql, "mysql"), + (DatabaseBackend::Sqlite, "sqlite"), + ] { + let sql = collect_all_sql(&result, backend); + with_settings!({ snapshot_suffix => format!("add_col_{}_{}", title, label) }, { + assert_snapshot!(sql); + }); + } + } + + // ── Remove FK/Unique/Index then drop column – all orderings ────────── + + #[rstest] + #[case::fk_unique_index("fk_uq_ix", &[fk_constraint(), unique_constraint(), index_constraint()])] + #[case::fk_index_unique("fk_ix_uq", &[fk_constraint(), index_constraint(), unique_constraint()])] + #[case::unique_fk_index("uq_fk_ix", &[unique_constraint(), fk_constraint(), index_constraint()])] + #[case::unique_index_fk("uq_ix_fk", &[unique_constraint(), index_constraint(), fk_constraint()])] + #[case::index_fk_unique("ix_fk_uq", &[index_constraint(), fk_constraint(), unique_constraint()])] + #[case::index_unique_fk("ix_uq_fk", &[index_constraint(), unique_constraint(), fk_constraint()])] + fn test_remove_fk_unique_index_then_drop_column_all_orderings( + #[case] title: &str, + #[case] order: &[TableConstraint], + ) { + let plan = plan_remove_constraints_then_drop(order); + let schema = base_schema_with_all_constraints(); + let result = build_plan_queries(&plan, &schema).unwrap(); + + // Snapshot per backend + for (backend, label) in [ + (DatabaseBackend::Postgres, "postgres"), + (DatabaseBackend::MySql, "mysql"), + (DatabaseBackend::Sqlite, "sqlite"), + ] { + let sql = collect_all_sql(&result, backend); + with_settings!({ snapshot_suffix => format!("rm_col_{}_{}", title, label) }, { + assert_snapshot!(sql); + }); + } + } + + // ── Pair-wise: FK + Index only (original bug scenario) ─────────────── + + #[rstest] + #[case::fk_then_index("fk_ix", &[fk_constraint(), index_constraint()])] + #[case::index_then_fk("ix_fk", &[index_constraint(), fk_constraint()])] + fn test_add_column_with_fk_and_index_pair( + #[case] title: &str, + #[case] order: &[TableConstraint], + ) { + let plan = plan_add_column_with_constraints(order); + let schema = base_schema_no_constraints(); + let result = build_plan_queries(&plan, &schema).unwrap(); + + assert_no_duplicate_indexes_per_action(&result); + assert_no_orphan_duplicate_indexes(&result); + + for (backend, label) in [ + (DatabaseBackend::Postgres, "postgres"), + (DatabaseBackend::MySql, "mysql"), + (DatabaseBackend::Sqlite, "sqlite"), + ] { + let sql = collect_all_sql(&result, backend); + with_settings!({ snapshot_suffix => format!("add_col_pair_{}_{}", title, label) }, { + assert_snapshot!(sql); + }); + } + } + + // ── Pair-wise: FK + Unique only ────────────────────────────────────── + + #[rstest] + #[case::fk_then_unique("fk_uq", &[fk_constraint(), unique_constraint()])] + #[case::unique_then_fk("uq_fk", &[unique_constraint(), fk_constraint()])] + fn test_add_column_with_fk_and_unique_pair( + #[case] title: &str, + #[case] order: &[TableConstraint], + ) { + let plan = plan_add_column_with_constraints(order); + let schema = base_schema_no_constraints(); + let result = build_plan_queries(&plan, &schema).unwrap(); + + assert_no_duplicate_indexes_per_action(&result); + assert_no_orphan_duplicate_indexes(&result); + + for (backend, label) in [ + (DatabaseBackend::Postgres, "postgres"), + (DatabaseBackend::MySql, "mysql"), + (DatabaseBackend::Sqlite, "sqlite"), + ] { + let sql = collect_all_sql(&result, backend); + with_settings!({ snapshot_suffix => format!("add_col_pair_{}_{}", title, label) }, { + assert_snapshot!(sql); + }); + } + } + + // ── Duplicate FK in temp table CREATE TABLE ────────────────────────── + + /// Regression test: when AddColumn adds a column with an inline FK, the + /// evolving schema already contains the FK constraint (from normalization). + /// Then AddConstraint FK pushes the same FK again into new_constraints, + /// producing a duplicate FOREIGN KEY clause in the SQLite temp table. + #[rstest] + #[case::postgres("postgres", DatabaseBackend::Postgres)] + #[case::mysql("mysql", DatabaseBackend::MySql)] + #[case::sqlite("sqlite", DatabaseBackend::Sqlite)] + fn test_add_column_with_fk_no_duplicate_fk_in_temp_table( + #[case] label: &str, + #[case] backend: DatabaseBackend, + ) { + let schema = vec![ + TableDef { + name: "project".into(), + description: None, + columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))], + constraints: vec![], + }, + TableDef { + name: "companion".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)), + ], + constraints: vec![ + TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + TableConstraint::Unique { + name: Some("invite_code".into()), + columns: vec!["invite_code".into()], + }, + TableConstraint::Index { + name: None, + columns: vec!["user_id".into()], + }, + ], + }, + ]; + + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 1, + actions: vec![ + MigrationAction::AddColumn { + table: "companion".into(), + column: Box::new(ColumnDef { + name: "project_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::BigInt), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: Some( + vespertide_core::schema::foreign_key::ForeignKeySyntax::String( + "project.id".into(), + ), + ), + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "companion".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["project_id".into()], + ref_table: "project".into(), + ref_columns: vec!["id".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }, + }, + MigrationAction::AddConstraint { + table: "companion".into(), + constraint: TableConstraint::Index { + name: None, + columns: vec!["project_id".into()], + }, + }, + ], + }; + + let result = build_plan_queries(&plan, &schema).unwrap(); + + assert_no_duplicate_indexes_per_action(&result); + assert_no_orphan_duplicate_indexes(&result); + + let sql = collect_all_sql(&result, backend); + with_settings!({ snapshot_suffix => format!("dup_fk_{}", label) }, { + assert_snapshot!(sql); + }); + } } diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_mysql.snap new file mode 100644 index 0000000..aca9f8c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_mysql.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_postgres.snap new file mode 100644 index 0000000..5f493b6 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_postgres.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_sqlite.snap new file mode 100644 index 0000000..8c901c8 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_fk_ix_sqlite.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_mysql.snap new file mode 100644 index 0000000..dc751e5 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_mysql.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_postgres.snap new file mode 100644 index 0000000..01b6288 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_postgres.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_sqlite.snap new file mode 100644 index 0000000..238b5d1 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_index_pair@add_col_pair_ix_fk_sqlite.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_mysql.snap new file mode 100644 index 0000000..7585208 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_mysql.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_postgres.snap new file mode 100644 index 0000000..a9848f0 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_postgres.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_sqlite.snap new file mode 100644 index 0000000..9063064 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_fk_uq_sqlite.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_mysql.snap new file mode 100644 index 0000000..cf1616f --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_mysql.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_postgres.snap new file mode 100644 index 0000000..d2337da --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_postgres.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_sqlite.snap new file mode 100644 index 0000000..4cd1fa1 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_and_unique_pair@add_col_pair_uq_fk_sqlite.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_mysql.snap new file mode 100644 index 0000000..521b0eb --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_mysql.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "companion", column: ColumnDef { name: "project_id", type: Simple(BigInt), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: Some(String("project.id")) }, fill_with: None } +ALTER TABLE `companion` ADD COLUMN `project_id` bigint NOT NULL + +-- Action 1: AddConstraint { table: "companion", constraint: ForeignKey { name: None, columns: ["project_id"], ref_table: "project", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `companion` ADD CONSTRAINT `fk_companion__project_id` FOREIGN KEY (`project_id`) REFERENCES `project` (`id`) ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "companion", constraint: Index { name: None, columns: ["project_id"] } } +CREATE INDEX `ix_companion__project_id` ON `companion` (`project_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_postgres.snap new file mode 100644 index 0000000..1ceab73 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_postgres.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "companion", column: ColumnDef { name: "project_id", type: Simple(BigInt), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: Some(String("project.id")) }, fill_with: None } +ALTER TABLE "companion" ADD COLUMN "project_id" bigint NOT NULL + +-- Action 1: AddConstraint { table: "companion", constraint: ForeignKey { name: None, columns: ["project_id"], ref_table: "project", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "companion" ADD CONSTRAINT "fk_companion__project_id" FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "companion", constraint: Index { name: None, columns: ["project_id"] } } +CREATE INDEX "ix_companion__project_id" ON "companion" ("project_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap new file mode 100644 index 0000000..5e76309 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "companion", column: ColumnDef { name: "project_id", type: Simple(BigInt), nullable: false, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: Some(String("project.id")) }, fill_with: None } +CREATE TABLE "companion_temp" ( "id" integer, "user_id" bigint, "project_id" bigint NOT NULL, FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ); +INSERT INTO "companion_temp" ("id", "user_id", "project_id") SELECT "id", "user_id", NULL AS "project_id" FROM "companion"; +DROP TABLE "companion"; +ALTER TABLE "companion_temp" RENAME TO "companion"; +CREATE UNIQUE INDEX "uq_companion__invite_code" ON "companion" ("invite_code"); +CREATE INDEX "ix_companion__user_id" ON "companion" ("user_id") + +-- Action 1: AddConstraint { table: "companion", constraint: ForeignKey { name: None, columns: ["project_id"], ref_table: "project", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "companion_temp" ( "id" integer, "user_id" bigint, "project_id" bigint NOT NULL, FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, FOREIGN KEY ("project_id") REFERENCES "project" ("id"), FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE ); +INSERT INTO "companion_temp" ("id", "user_id", "project_id") SELECT "id", "user_id", "project_id" FROM "companion"; +DROP TABLE "companion"; +ALTER TABLE "companion_temp" RENAME TO "companion"; +CREATE UNIQUE INDEX "uq_companion__invite_code" ON "companion" ("invite_code"); +CREATE INDEX "ix_companion__user_id" ON "companion" ("user_id") + +-- Action 2: AddConstraint { table: "companion", constraint: Index { name: None, columns: ["project_id"] } } +CREATE INDEX "ix_companion__project_id" ON "companion" ("project_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_mysql.snap new file mode 100644 index 0000000..a68c0b1 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) + +-- Action 3: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_postgres.snap new file mode 100644 index 0000000..0b8373f --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_sqlite.snap new file mode 100644 index 0000000..854f287 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_ix_uq_sqlite.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_mysql.snap new file mode 100644 index 0000000..22a5442 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) + +-- Action 3: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_postgres.snap new file mode 100644 index 0000000..d54322c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_sqlite.snap new file mode 100644 index 0000000..8c11935 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_fk_uq_ix_sqlite.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_mysql.snap new file mode 100644 index 0000000..8c17b4c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE + +-- Action 3: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_postgres.snap new file mode 100644 index 0000000..919835c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE + +-- Action 3: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_sqlite.snap new file mode 100644 index 0000000..4cc7f18 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_fk_uq_sqlite.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_mysql.snap new file mode 100644 index 0000000..a390e68 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) + +-- Action 3: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_postgres.snap new file mode 100644 index 0000000..f5d9809 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_sqlite.snap new file mode 100644 index 0000000..67ad4bb --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_ix_uq_fk_sqlite.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id"); +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_mysql.snap new file mode 100644 index 0000000..a526f16 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE + +-- Action 3: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_postgres.snap new file mode 100644 index 0000000..3ae4a22 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE + +-- Action 3: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_sqlite.snap new file mode 100644 index 0000000..0079804 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_fk_ix_sqlite.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_mysql.snap new file mode 100644 index 0000000..d0010eb --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE `product` ADD COLUMN `category_id` bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX `uq_product__category_id` ON `product` (`category_id`) + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX `ix_product__category_id` ON `product` (`category_id`) + +-- Action 3: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` ADD CONSTRAINT `fk_product__category_id` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_postgres.snap new file mode 100644 index 0000000..dc4ffab --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" ADD CONSTRAINT "fk_product__category_id" FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_sqlite.snap new file mode 100644 index 0000000..41b6ae9 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_unique_index_all_orderings@add_col_uq_ix_fk_sqlite.snap @@ -0,0 +1,20 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: AddColumn { table: "product", column: ColumnDef { name: "category_id", type: Simple(BigInt), nullable: true, default: None, comment: None, primary_key: None, unique: None, index: None, foreign_key: None }, fill_with: None } +ALTER TABLE "product" ADD COLUMN "category_id" bigint + +-- Action 1: AddConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: AddConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 3: AddConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id"); +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_mysql.snap new file mode 100644 index 0000000..6195f9d --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` DROP FOREIGN KEY `fk_product__category_id` + +-- Action 1: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX `ix_product__category_id` ON `product` + +-- Action 2: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +ALTER TABLE `product` DROP INDEX `uq_product__category_id` + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE `product` DROP COLUMN `category_id` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_postgres.snap new file mode 100644 index 0000000..82834ab --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" DROP CONSTRAINT "fk_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +DROP INDEX "uq_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_sqlite.snap new file mode 100644 index 0000000..2d5ea05 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_ix_uq_sqlite.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id"); +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 1: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_mysql.snap new file mode 100644 index 0000000..6338e5c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` DROP FOREIGN KEY `fk_product__category_id` + +-- Action 1: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +ALTER TABLE `product` DROP INDEX `uq_product__category_id` + +-- Action 2: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX `ix_product__category_id` ON `product` + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE `product` DROP COLUMN `category_id` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_postgres.snap new file mode 100644 index 0000000..2292456 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" DROP CONSTRAINT "fk_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +DROP INDEX "uq_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_sqlite.snap new file mode 100644 index 0000000..5dbbc3f --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_fk_uq_ix_sqlite.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id"); +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 1: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_mysql.snap new file mode 100644 index 0000000..bd88b1a --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX `ix_product__category_id` ON `product` + +-- Action 1: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` DROP FOREIGN KEY `fk_product__category_id` + +-- Action 2: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +ALTER TABLE `product` DROP INDEX `uq_product__category_id` + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE `product` DROP COLUMN `category_id` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_postgres.snap new file mode 100644 index 0000000..cda8864 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" DROP CONSTRAINT "fk_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +DROP INDEX "uq_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_sqlite.snap new file mode 100644 index 0000000..45688f2 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_fk_uq_sqlite.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE UNIQUE INDEX "uq_product__category_id" ON "product" ("category_id") + +-- Action 2: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_mysql.snap new file mode 100644 index 0000000..f3a21d6 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX `ix_product__category_id` ON `product` + +-- Action 1: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +ALTER TABLE `product` DROP INDEX `uq_product__category_id` + +-- Action 2: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` DROP FOREIGN KEY `fk_product__category_id` + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE `product` DROP COLUMN `category_id` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_postgres.snap new file mode 100644 index 0000000..b49782c --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +DROP INDEX "uq_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" DROP CONSTRAINT "fk_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_sqlite.snap new file mode 100644 index 0000000..59e857b --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_ix_uq_fk_sqlite.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 2: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_mysql.snap new file mode 100644 index 0000000..0348cd7 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +ALTER TABLE `product` DROP INDEX `uq_product__category_id` + +-- Action 1: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` DROP FOREIGN KEY `fk_product__category_id` + +-- Action 2: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX `ix_product__category_id` ON `product` + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE `product` DROP COLUMN `category_id` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_postgres.snap new file mode 100644 index 0000000..9996ea5 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +DROP INDEX "uq_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" DROP CONSTRAINT "fk_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_sqlite.snap new file mode 100644 index 0000000..1609734 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_fk_ix_sqlite.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 1: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 2: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_mysql.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_mysql.snap new file mode 100644 index 0000000..a169454 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_mysql.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +ALTER TABLE `product` DROP INDEX `uq_product__category_id` + +-- Action 1: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX `ix_product__category_id` ON `product` + +-- Action 2: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE `product` DROP FOREIGN KEY `fk_product__category_id` + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE `product` DROP COLUMN `category_id` diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_postgres.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_postgres.snap new file mode 100644 index 0000000..728b127 --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_postgres.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +DROP INDEX "uq_product__category_id" + +-- Action 1: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +ALTER TABLE "product" DROP CONSTRAINT "fk_product__category_id" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_sqlite.snap new file mode 100644 index 0000000..21b5dae --- /dev/null +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__remove_fk_unique_index_then_drop_column_all_orderings@rm_col_uq_ix_fk_sqlite.snap @@ -0,0 +1,22 @@ +--- +source: crates/vespertide-query/src/builder.rs +expression: sql +--- +-- Action 0: RemoveConstraint { table: "product", constraint: Unique { name: None, columns: ["category_id"] } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint, FOREIGN KEY ("category_id") REFERENCES "category" ("id") ON DELETE CASCADE ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product"; +CREATE INDEX "ix_product__category_id" ON "product" ("category_id") + +-- Action 1: RemoveConstraint { table: "product", constraint: Index { name: None, columns: ["category_id"] } } +DROP INDEX "ix_product__category_id" + +-- Action 2: RemoveConstraint { table: "product", constraint: ForeignKey { name: None, columns: ["category_id"], ref_table: "category", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } +CREATE TABLE "product_temp" ( "id" integer, "category_id" bigint ); +INSERT INTO "product_temp" ("id", "category_id") SELECT "id", "category_id" FROM "product"; +DROP TABLE "product"; +ALTER TABLE "product_temp" RENAME TO "product" + +-- Action 3: DeleteColumn { table: "product", column: "category_id" } +ALTER TABLE "product" DROP COLUMN "category_id" diff --git a/crates/vespertide-query/src/sql/add_column.rs b/crates/vespertide-query/src/sql/add_column.rs index a8b1753..4aca993 100644 --- a/crates/vespertide-query/src/sql/add_column.rs +++ b/crates/vespertide-query/src/sql/add_column.rs @@ -96,7 +96,7 @@ pub fn build_add_column( let rename_query = build_rename_table(&temp_table, table); // Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints, &[]); let mut stmts = vec![create_query, insert_query, drop_query, rename_query]; stmts.extend(index_queries); diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index b86888d..f16fcc4 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -14,6 +14,7 @@ pub fn build_add_constraint( table: &str, constraint: &TableConstraint, current_schema: &[TableDef], + pending_constraints: &[TableConstraint], ) -> Result, QueryError> { match constraint { TableConstraint::PrimaryKey { columns, .. } => { @@ -65,7 +66,12 @@ pub fn build_add_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + // Exclude pending constraints that will be created by future AddConstraint actions + let index_queries = recreate_indexes_after_rebuild( + table, + &table_def.constraints, + pending_constraints, + ); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -161,7 +167,12 @@ pub fn build_add_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + // Exclude pending constraints that will be created by future AddConstraint actions + let index_queries = recreate_indexes_after_rebuild( + table, + &table_def.constraints, + pending_constraints, + ); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -249,7 +260,12 @@ pub fn build_add_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + // Exclude pending constraints that will be created by future AddConstraint actions + let index_queries = recreate_indexes_after_rebuild( + table, + &table_def.constraints, + pending_constraints, + ); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -437,7 +453,8 @@ mod tests { constraints: vec![], }]; - let result = build_add_constraint(&backend, "users", &constraint, ¤t_schema).unwrap(); + let result = + build_add_constraint(&backend, "users", &constraint, ¤t_schema, &[]).unwrap(); let sql = result[0].build(backend); for exp in expected { assert!( @@ -465,6 +482,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); @@ -501,6 +519,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -543,6 +562,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -588,6 +608,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -616,6 +637,7 @@ mod tests { "posts", &constraint, ¤t_schema, + &[], ); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); @@ -656,6 +678,7 @@ mod tests { "posts", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -702,6 +725,7 @@ mod tests { "posts", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -750,6 +774,7 @@ mod tests { "posts", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -774,6 +799,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); @@ -808,6 +834,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -850,6 +877,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -896,6 +924,7 @@ mod tests { "posts", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -939,6 +968,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); @@ -983,6 +1013,7 @@ mod tests { "users", &constraint, ¤t_schema, + &[], ); assert!(result.is_ok()); let queries = result.unwrap(); diff --git a/crates/vespertide-query/src/sql/delete_column.rs b/crates/vespertide-query/src/sql/delete_column.rs index be00c18..29ff3ea 100644 --- a/crates/vespertide-query/src/sql/delete_column.rs +++ b/crates/vespertide-query/src/sql/delete_column.rs @@ -183,7 +183,7 @@ fn build_delete_column_sqlite_temp_table( stmts.push(build_rename_table(&temp_table, table)); // 5. Recreate indexes (both regular and UNIQUE) that don't reference the deleted column - stmts.extend(recreate_indexes_after_rebuild(table, &new_constraints)); + stmts.extend(recreate_indexes_after_rebuild(table, &new_constraints, &[])); // If column type is an enum, drop the type after (PostgreSQL only, but include for completeness) if let Some(col_type) = column_type diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 6eba576..3646615 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -462,12 +462,21 @@ pub fn build_sqlite_temp_table_create( /// Recreate all indexes (both regular and UNIQUE) after a SQLite temp table rebuild. /// After DROP TABLE + RENAME, all original indexes are gone, so plain CREATE INDEX is correct. +/// +/// `pending_constraints` are constraints that exist in the logical schema but haven't been +/// physically created yet (e.g., promoted from inline column definitions by AddColumn normalization). +/// These will be created by separate AddConstraint actions later, so we must NOT recreate them here. pub fn recreate_indexes_after_rebuild( table: &str, constraints: &[TableConstraint], + pending_constraints: &[TableConstraint], ) -> Vec { let mut queries = Vec::new(); for constraint in constraints { + // Skip constraints that will be created by future AddConstraint actions + if pending_constraints.contains(constraint) { + continue; + } match constraint { TableConstraint::Index { name, columns } => { let index_name = build_index_name(table, columns, name.as_deref()); diff --git a/crates/vespertide-query/src/sql/mod.rs b/crates/vespertide-query/src/sql/mod.rs index 327fec1..12e819f 100644 --- a/crates/vespertide-query/src/sql/mod.rs +++ b/crates/vespertide-query/src/sql/mod.rs @@ -18,7 +18,7 @@ pub use helpers::*; pub use types::{BuiltQuery, DatabaseBackend, RawSql}; use crate::error::QueryError; -use vespertide_core::{MigrationAction, TableDef}; +use vespertide_core::{MigrationAction, TableConstraint, TableDef}; use self::{ add_column::build_add_column, add_constraint::build_add_constraint, @@ -35,6 +35,20 @@ pub fn build_action_queries( backend: &DatabaseBackend, action: &MigrationAction, current_schema: &[TableDef], +) -> Result, QueryError> { + build_action_queries_with_pending(backend, action, current_schema, &[]) +} + +/// Build SQL queries for a migration action, with awareness of pending constraints. +/// +/// `pending_constraints` are constraints that exist in the logical schema but haven't been +/// physically created as database indexes yet. This is used by SQLite temp table rebuilds +/// to avoid recreating indexes that will be created by future AddConstraint actions. +pub fn build_action_queries_with_pending( + backend: &DatabaseBackend, + action: &MigrationAction, + current_schema: &[TableDef], + pending_constraints: &[TableConstraint], ) -> Result, QueryError> { match action { MigrationAction::CreateTable { @@ -120,7 +134,7 @@ pub fn build_action_queries( MigrationAction::RawSql { sql } => Ok(vec![build_raw_sql(sql.clone())]), MigrationAction::AddConstraint { table, constraint } => { - build_add_constraint(backend, table, constraint, current_schema) + build_add_constraint(backend, table, constraint, current_schema, pending_constraints) } MigrationAction::RemoveConstraint { table, constraint } => { diff --git a/crates/vespertide-query/src/sql/modify_column_default.rs b/crates/vespertide-query/src/sql/modify_column_default.rs index 2af382e..4e173fd 100644 --- a/crates/vespertide-query/src/sql/modify_column_default.rs +++ b/crates/vespertide-query/src/sql/modify_column_default.rs @@ -142,6 +142,7 @@ pub fn build_modify_column_default( queries.extend(recreate_indexes_after_rebuild( table, &table_def.constraints, + &[], )); } } diff --git a/crates/vespertide-query/src/sql/modify_column_nullable.rs b/crates/vespertide-query/src/sql/modify_column_nullable.rs index e6be72b..eba103e 100644 --- a/crates/vespertide-query/src/sql/modify_column_nullable.rs +++ b/crates/vespertide-query/src/sql/modify_column_nullable.rs @@ -130,6 +130,7 @@ pub fn build_modify_column_nullable( queries.extend(recreate_indexes_after_rebuild( table, &table_def.constraints, + &[], )); } } diff --git a/crates/vespertide-query/src/sql/modify_column_type.rs b/crates/vespertide-query/src/sql/modify_column_type.rs index 7050b0b..694b814 100644 --- a/crates/vespertide-query/src/sql/modify_column_type.rs +++ b/crates/vespertide-query/src/sql/modify_column_type.rs @@ -76,7 +76,7 @@ pub fn build_modify_column_type( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints, &[]); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); diff --git a/crates/vespertide-query/src/sql/remove_constraint.rs b/crates/vespertide-query/src/sql/remove_constraint.rs index 22134d3..e15d95b 100644 --- a/crates/vespertide-query/src/sql/remove_constraint.rs +++ b/crates/vespertide-query/src/sql/remove_constraint.rs @@ -63,7 +63,8 @@ pub fn build_remove_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + let index_queries = + recreate_indexes_after_rebuild(table, &table_def.constraints, &[]); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -152,7 +153,7 @@ pub fn build_remove_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE, from new_constraints which has the unique removed) - let index_queries = recreate_indexes_after_rebuild(table, &new_constraints); + let index_queries = recreate_indexes_after_rebuild(table, &new_constraints, &[]); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -247,7 +248,8 @@ pub fn build_remove_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + let index_queries = + recreate_indexes_after_rebuild(table, &table_def.constraints, &[]); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); @@ -333,7 +335,8 @@ pub fn build_remove_constraint( let rename_query = build_rename_table(&temp_table, table); // 5. Recreate indexes (both regular and UNIQUE) - let index_queries = recreate_indexes_after_rebuild(table, &table_def.constraints); + let index_queries = + recreate_indexes_after_rebuild(table, &table_def.constraints, &[]); let mut queries = vec![create_query, insert_query, drop_query, rename_query]; queries.extend(index_queries); From fe2c2c8ff50f948b3c089741c4f4099f093f0415 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 23:03:35 +0900 Subject: [PATCH 10/17] Fix sqlite fk dup issue --- ...licate_fk_in_temp_table@dup_fk_sqlite.snap | 2 +- .../src/sql/add_constraint.rs | 74 +++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap index 5e76309..3c98773 100644 --- a/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap +++ b/crates/vespertide-query/src/snapshots/vespertide_query__builder__tests__add_column_with_fk_no_duplicate_fk_in_temp_table@dup_fk_sqlite.snap @@ -11,7 +11,7 @@ CREATE UNIQUE INDEX "uq_companion__invite_code" ON "companion" ("invite_code"); CREATE INDEX "ix_companion__user_id" ON "companion" ("user_id") -- Action 1: AddConstraint { table: "companion", constraint: ForeignKey { name: None, columns: ["project_id"], ref_table: "project", ref_columns: ["id"], on_delete: Some(Cascade), on_update: None } } -CREATE TABLE "companion_temp" ( "id" integer, "user_id" bigint, "project_id" bigint NOT NULL, FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, FOREIGN KEY ("project_id") REFERENCES "project" ("id"), FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE ); +CREATE TABLE "companion_temp" ( "id" integer, "user_id" bigint, "project_id" bigint NOT NULL, FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE ); INSERT INTO "companion_temp" ("id", "user_id", "project_id") SELECT "id", "user_id", "project_id" FROM "companion"; DROP TABLE "companion"; ALTER TABLE "companion_temp" RENAME TO "companion"; diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index f16fcc4..a81ca08 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -5,6 +5,71 @@ use vespertide_core::{TableConstraint, TableDef}; use super::helpers::{ build_sqlite_temp_table_create, recreate_indexes_after_rebuild, to_sea_fk_action, }; + +/// Build new_constraints by cloning `table_def.constraints` and adding the +/// given `constraint`. If an equivalent constraint already exists (e.g. +/// promoted by AddColumn normalization), it is **replaced** so that the +/// explicit AddConstraint version (which may carry ON DELETE / ON UPDATE) +/// wins, without producing duplicates. +fn merge_constraint( + existing: &[TableConstraint], + constraint: &TableConstraint, +) -> Vec { + let mut out: Vec = Vec::with_capacity(existing.len() + 1); + let mut replaced = false; + + for c in existing { + if constraints_overlap(c, constraint) { + // Replace the existing (possibly weaker) constraint with the new one. + if !replaced { + out.push(constraint.clone()); + replaced = true; + } + // else: skip additional duplicates + } else { + out.push(c.clone()); + } + } + + if !replaced { + out.push(constraint.clone()); + } + out +} + +/// Two constraints "overlap" when they are the same variant and target the +/// same columns, even if their details (name, on_delete, …) differ. +fn constraints_overlap(a: &TableConstraint, b: &TableConstraint) -> bool { + match (a, b) { + ( + TableConstraint::ForeignKey { + columns: a_cols, .. + }, + TableConstraint::ForeignKey { + columns: b_cols, .. + }, + ) => a_cols == b_cols, + ( + TableConstraint::PrimaryKey { + columns: a_cols, .. + }, + TableConstraint::PrimaryKey { + columns: b_cols, .. + }, + ) => a_cols == b_cols, + ( + TableConstraint::Check { + name: a_name, + expr: a_expr, + }, + TableConstraint::Check { + name: b_name, + expr: b_expr, + }, + ) => a_name == b_name && a_expr == b_expr, + _ => false, + } +} use super::rename_table::build_rename_table; use super::types::{BuiltQuery, DatabaseBackend, RawSql}; use crate::error::QueryError; @@ -24,8 +89,7 @@ pub fn build_add_constraint( let table_def = current_schema.iter().find(|t| t.name == table).ok_or_else(|| QueryError::Other(format!("Table '{}' not found in current schema. SQLite requires current schema information to add constraints.", table)))?; // Create new constraints with the added primary key constraint - let mut new_constraints: Vec = table_def.constraints.clone(); - new_constraints.push(constraint.clone()); + let new_constraints = merge_constraint(&table_def.constraints, constraint); let temp_table = format!("{}_temp", table); @@ -125,8 +189,7 @@ pub fn build_add_constraint( let table_def = current_schema.iter().find(|t| t.name == table).ok_or_else(|| QueryError::Other(format!("Table '{}' not found in current schema. SQLite requires current schema information to add constraints.", table)))?; // Create new constraints with the added foreign key constraint - let mut new_constraints = table_def.constraints.clone(); - new_constraints.push(constraint.clone()); + let new_constraints = merge_constraint(&table_def.constraints, constraint); let temp_table = format!("{}_temp", table); @@ -218,8 +281,7 @@ pub fn build_add_constraint( let table_def = current_schema.iter().find(|t| t.name == table).ok_or_else(|| QueryError::Other(format!("Table '{}' not found in current schema. SQLite requires current schema information to add constraints.", table)))?; // Create new constraints with the added check constraint - let mut new_constraints = table_def.constraints.clone(); - new_constraints.push(constraint.clone()); + let new_constraints = merge_constraint(&table_def.constraints, constraint); let temp_table = format!("{}_temp", table); From 98978177c20a8c90acd2b477272768075cf3b496 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 23:06:02 +0900 Subject: [PATCH 11/17] Fix test issue --- crates/vespertide-macro/src/lib.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index fbfef84..4e8b01f 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -54,6 +54,14 @@ impl Parse for MacroInput { } } +/// Build a migration block (non-verbose). +pub(crate) fn build_migration_block( + migration: &vespertide_core::MigrationPlan, + baseline_schema: &mut Vec, +) -> Result { + build_migration_block_inner(migration, baseline_schema, false) +} + /// Build a migration block with optional verbose logging. pub(crate) fn build_migration_block_verbose( migration: &vespertide_core::MigrationPlan, @@ -217,6 +225,14 @@ fn build_migration_block_inner( Ok(block) } +fn generate_migration_code( + pool: &Expr, + version_table: &str, + migration_blocks: Vec, +) -> proc_macro2::TokenStream { + generate_migration_code_inner(pool, version_table, migration_blocks, false) +} + fn generate_migration_code_inner( pool: &Expr, version_table: &str, From ce1a4a61e4e5fb2dfdee07358883b6b6fe615eb9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 23:11:09 +0900 Subject: [PATCH 12/17] Cleanup func --- crates/vespertide-macro/src/lib.rs | 59 +++++++++--------------------- 1 file changed, 17 insertions(+), 42 deletions(-) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 4e8b01f..f864122 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -54,26 +54,9 @@ impl Parse for MacroInput { } } -/// Build a migration block (non-verbose). pub(crate) fn build_migration_block( migration: &vespertide_core::MigrationPlan, baseline_schema: &mut Vec, -) -> Result { - build_migration_block_inner(migration, baseline_schema, false) -} - -/// Build a migration block with optional verbose logging. -pub(crate) fn build_migration_block_verbose( - migration: &vespertide_core::MigrationPlan, - baseline_schema: &mut Vec, - verbose: bool, -) -> Result { - build_migration_block_inner(migration, baseline_schema, verbose) -} - -fn build_migration_block_inner( - migration: &vespertide_core::MigrationPlan, - baseline_schema: &mut Vec, verbose: bool, ) -> Result { let version = migration.version; @@ -229,14 +212,6 @@ fn generate_migration_code( pool: &Expr, version_table: &str, migration_blocks: Vec, -) -> proc_macro2::TokenStream { - generate_migration_code_inner(pool, version_table, migration_blocks, false) -} - -fn generate_migration_code_inner( - pool: &Expr, - version_table: &str, - migration_blocks: Vec, verbose: bool, ) -> proc_macro2::TokenStream { let verbose_current_version = if verbose { @@ -366,7 +341,7 @@ pub(crate) fn vespertide_migration_impl( for migration in &migrations { // Apply prefix to migration table names let prefixed_migration = migration.clone().with_prefix(prefix); - match build_migration_block_verbose(&prefixed_migration, &mut baseline_schema, verbose) { + match build_migration_block(&prefixed_migration, &mut baseline_schema, verbose) { Ok(block) => migration_blocks.push(block), Err(e) => { return syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error(); @@ -374,7 +349,7 @@ pub(crate) fn vespertide_migration_impl( } } - generate_migration_code_inner(pool, &version_table, migration_blocks, verbose) + generate_migration_code(pool, &version_table, migration_blocks, verbose) } /// Zero-runtime migration entry point. @@ -511,7 +486,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline); + let result = build_migration_block(&migration, &mut baseline, false); assert!(result.is_ok()); let block = result.unwrap(); @@ -541,7 +516,7 @@ mod tests { }; let mut baseline = Vec::new(); - let _ = build_migration_block(&create_migration, &mut baseline); + let _ = build_migration_block(&create_migration, &mut baseline, false); // Now add a column let add_column_migration = MigrationPlan { @@ -565,7 +540,7 @@ mod tests { }], }; - let result = build_migration_block(&add_column_migration, &mut baseline); + let result = build_migration_block(&add_column_migration, &mut baseline, false); assert!(result.is_ok()); let block = result.unwrap(); let block_str = block.to_string(); @@ -596,7 +571,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline); + let result = build_migration_block(&migration, &mut baseline, false); assert!(result.is_ok()); assert_eq!(baseline.len(), 2); @@ -620,9 +595,9 @@ mod tests { }; let mut baseline = Vec::new(); - let block = build_migration_block(&migration, &mut baseline).unwrap(); + let block = build_migration_block(&migration, &mut baseline, false).unwrap(); - let generated = generate_migration_code(&pool, version_table, vec![block]); + let generated = generate_migration_code(&pool, version_table, vec![block], false); let generated_str = generated.to_string(); // Verify the generated code structure @@ -638,7 +613,7 @@ mod tests { let pool: Expr = syn::parse_str("pool").unwrap(); let version_table = "vespertide_version"; - let generated = generate_migration_code(&pool, version_table, vec![]); + let generated = generate_migration_code(&pool, version_table, vec![], false); let generated_str = generated.to_string(); // Should still generate the wrapper code @@ -662,7 +637,7 @@ mod tests { constraints: vec![], }], }; - let block1 = build_migration_block(&migration1, &mut baseline).unwrap(); + let block1 = build_migration_block(&migration1, &mut baseline, false).unwrap(); let migration2 = MigrationPlan { version: 2, @@ -674,9 +649,9 @@ mod tests { constraints: vec![], }], }; - let block2 = build_migration_block(&migration2, &mut baseline).unwrap(); + let block2 = build_migration_block(&migration2, &mut baseline, false).unwrap(); - let generated = generate_migration_code(&pool, "migrations", vec![block1, block2]); + let generated = generate_migration_code(&pool, "migrations", vec![block1, block2], false); let generated_str = generated.to_string(); // Both version checks should be present @@ -698,7 +673,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline); + let result = build_migration_block(&migration, &mut baseline, false); assert!(result.is_ok()); let block_str = result.unwrap().to_string(); @@ -724,7 +699,7 @@ mod tests { }; let mut baseline = Vec::new(); - let _ = build_migration_block(&create_migration, &mut baseline); + let _ = build_migration_block(&create_migration, &mut baseline, false); assert_eq!(baseline.len(), 1); // Now delete it @@ -737,7 +712,7 @@ mod tests { }], }; - let result = build_migration_block(&delete_migration, &mut baseline); + let result = build_migration_block(&delete_migration, &mut baseline, false); assert!(result.is_ok()); let block_str = result.unwrap().to_string(); assert!(block_str.contains("DROP TABLE")); @@ -773,7 +748,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline); + let result = build_migration_block(&migration, &mut baseline, false); assert!(result.is_ok()); // Table should be normalized with index @@ -797,7 +772,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline); + let result = build_migration_block(&migration, &mut baseline, false); assert!(result.is_err()); let err = result.unwrap_err(); From d6ef640b7d87a5cafb00edd4129e8f29cbd94b4a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 23:23:10 +0900 Subject: [PATCH 13/17] Fix diff enum issue --- crates/vespertide-cli/src/commands/diff.rs | 14 ++-- crates/vespertide-core/src/schema/column.rs | 7 +- crates/vespertide-planner/src/diff.rs | 76 +++++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/crates/vespertide-cli/src/commands/diff.rs b/crates/vespertide-cli/src/commands/diff.rs index df34a97..f77492f 100644 --- a/crates/vespertide-cli/src/commands/diff.rs +++ b/crates/vespertide-cli/src/commands/diff.rs @@ -81,12 +81,18 @@ fn format_action(action: &MigrationAction) -> String { column.bright_cyan().bold() ) } - MigrationAction::ModifyColumnType { table, column, .. } => { + MigrationAction::ModifyColumnType { + table, + column, + new_type, + } => { format!( - "{} {}.{}", + "{} {}.{} {} {}", "Modify column type:".bright_yellow(), table.bright_cyan(), - column.bright_cyan().bold() + column.bright_cyan().bold(), + "->".bright_white(), + new_type.to_display_string().bright_cyan().bold() ) } MigrationAction::ModifyColumnNullable { @@ -324,7 +330,7 @@ mod tests { column: "id".into(), new_type: ColumnType::Simple(SimpleColumnType::Integer), }, - format!("{} {}.{}", "Modify column type:".bright_yellow(), "users".bright_cyan(), "id".bright_cyan().bold()) + format!("{} {}.{} {} {}", "Modify column type:".bright_yellow(), "users".bright_cyan(), "id".bright_cyan().bold(), "->".bright_white(), "integer".bright_cyan().bold()) )] #[case( MigrationAction::AddConstraint { diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 72d6e58..5900eff 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -61,8 +61,11 @@ impl ColumnType { if values1.is_integer() && values2.is_integer() { false } else { - // At least one is string enum - compare fully - self != other + // String enums: compare only values, not name. + // The enum name is a user-facing label; the actual DB type name + // is auto-generated with a table prefix at SQL generation time. + // Different labels with identical values don't require a migration. + values1 != values2 } } _ => self != other, diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 352e1a6..04ff1af 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -894,6 +894,82 @@ mod tests { if table == "orders" && column == "status" )); } + + #[test] + fn string_enum_name_changed_same_values_no_migration() { + // String enum name changed but values identical - should NOT generate migration + // Reproduces the phantom diff: model "status" vs migration "article_status" + let from = vec![table( + "orders", + vec![col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + )], + vec![], + )]; + + let to = vec![table( + "orders", + vec![col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + )], + vec![], + )]; + + let plan = diff_schemas(&from, &to).unwrap(); + assert!( + plan.actions.is_empty(), + "Expected no actions for enum name-only change, got: {:?}", + plan.actions + ); + } + + #[test] + fn string_enum_name_and_values_changed_requires_migration() { + // String enum name AND values changed - SHOULD generate ModifyColumnType + let from = vec![table( + "orders", + vec![col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".into(), + values: EnumValues::String(vec!["pending".into(), "shipped".into()]), + }), + )], + vec![], + )]; + + let to = vec![table( + "orders", + vec![col( + "status", + ColumnType::Complex(ComplexColumnType::Enum { + name: "status".into(), + values: EnumValues::String(vec![ + "pending".into(), + "shipped".into(), + "delivered".into(), + ]), + }), + )], + vec![], + )]; + + let plan = diff_schemas(&from, &to).unwrap(); + assert_eq!(plan.actions.len(), 1); + assert!(matches!( + &plan.actions[0], + MigrationAction::ModifyColumnType { table, column, .. } + if table == "orders" && column == "status" + )); + } } // Tests for enum + default value ordering From 504e856c8c04fbadbf5bd617dd81bb752a0e0639 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 2 Feb 2026 23:42:18 +0900 Subject: [PATCH 14/17] Fix lint --- crates/vespertide-macro/src/lib.rs | 2 +- crates/vespertide-query/src/builder.rs | 4 ++-- crates/vespertide-query/src/sql/mod.rs | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index f864122..99f3c90 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -11,7 +11,7 @@ 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::{build_plan_queries, DatabaseBackend}; +use vespertide_query::{DatabaseBackend, build_plan_queries}; struct MacroInput { pool: Expr, diff --git a/crates/vespertide-query/src/builder.rs b/crates/vespertide-query/src/builder.rs index 206a97e..e48397f 100644 --- a/crates/vespertide-query/src/builder.rs +++ b/crates/vespertide-query/src/builder.rs @@ -1,10 +1,10 @@ use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; use vespertide_planner::apply_action; +use crate::DatabaseBackend; use crate::error::QueryError; -use crate::sql::build_action_queries_with_pending; use crate::sql::BuiltQuery; -use crate::DatabaseBackend; +use crate::sql::build_action_queries_with_pending; pub struct PlanQueries { pub action: MigrationAction, diff --git a/crates/vespertide-query/src/sql/mod.rs b/crates/vespertide-query/src/sql/mod.rs index 12e819f..acfe0bf 100644 --- a/crates/vespertide-query/src/sql/mod.rs +++ b/crates/vespertide-query/src/sql/mod.rs @@ -133,9 +133,13 @@ pub fn build_action_queries_with_pending( MigrationAction::RawSql { sql } => Ok(vec![build_raw_sql(sql.clone())]), - MigrationAction::AddConstraint { table, constraint } => { - build_add_constraint(backend, table, constraint, current_schema, pending_constraints) - } + MigrationAction::AddConstraint { table, constraint } => build_add_constraint( + backend, + table, + constraint, + current_schema, + pending_constraints, + ), MigrationAction::RemoveConstraint { table, constraint } => { build_remove_constraint(backend, table, constraint, current_schema) From 29f9af5564e36cd42586d8acb8a07017d0ef821a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 3 Feb 2026 00:08:18 +0900 Subject: [PATCH 15/17] Block adding new column with fk and non null --- .../vespertide-cli/src/commands/revision.rs | 156 +++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/crates/vespertide-cli/src/commands/revision.rs b/crates/vespertide-cli/src/commands/revision.rs index be00279..b262ae5 100644 --- a/crates/vespertide-cli/src/commands/revision.rs +++ b/crates/vespertide-cli/src/commands/revision.rs @@ -7,7 +7,7 @@ use colored::Colorize; use dialoguer::{Input, Select}; use serde_json::Value; use vespertide_config::FileFormat; -use vespertide_core::{MigrationAction, MigrationPlan, TableDef}; +use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef}; use vespertide_planner::{find_missing_fill_with, plan_next_migration, schema_from_plans}; use crate::utils::{ @@ -225,6 +225,43 @@ where Ok(()) } +/// Check that no AddColumn action adds a non-nullable FK column without a default. +/// This is logically impossible: existing rows can't satisfy the FK constraint. +fn check_non_nullable_fk_add_columns(plan: &MigrationPlan) -> Result<()> { + use std::collections::HashSet; + + // Collect FK columns from AddConstraint actions + let mut fk_columns: HashSet<(String, String)> = HashSet::new(); + for action in &plan.actions { + if let MigrationAction::AddConstraint { + table, + constraint: TableConstraint::ForeignKey { columns, .. }, + } = action + { + for col in columns { + fk_columns.insert((table.clone(), col.to_string())); + } + } + } + + for action in &plan.actions { + if let MigrationAction::AddColumn { table, column, .. } = action { + let has_fk = column.foreign_key.is_some() + || fk_columns.contains(&(table.clone(), column.name.to_string())); + if has_fk && !column.nullable && column.default.is_none() { + anyhow::bail!( + "Cannot add non-nullable foreign key column '{}' to existing table '{}': \ + existing rows cannot satisfy the foreign key constraint. \ + Make the column nullable, or add it with a default value that references an existing row.", + column.name, + table + ); + } + } + } + Ok(()) +} + pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> { let config = load_config()?; let current_models = load_models(&config)?; @@ -242,6 +279,10 @@ pub fn cmd_revision(message: String, fill_with_args: Vec) -> Result<()> return Ok(()); } + // Fail early: non-nullable FK column cannot be added to an existing table. + // Even with fill_with, there's no way to guarantee the value references a valid row. + check_non_nullable_fk_add_columns(&plan)?; + // Reconstruct baseline schema for column type lookups let baseline_schema = schema_from_plans(&applied_plans) .map_err(|e| anyhow::anyhow!("schema reconstruction error: {}", e))?; @@ -473,6 +514,119 @@ mod tests { assert!(has_yaml); } + #[test] + fn check_non_nullable_fk_add_column_fails() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: Some("1".into()), + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + let result = check_non_nullable_fk_add_columns(&plan); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("user_id"), + "error should mention column: {msg}" + ); + assert!(msg.contains("post"), "error should mention table: {msg}"); + } + + #[test] + fn check_nullable_fk_add_column_ok() { + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 2, + actions: vec![ + MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }, + MigrationAction::AddConstraint { + table: "post".into(), + constraint: TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "user".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }, + }, + ], + }; + assert!(check_non_nullable_fk_add_columns(&plan).is_ok()); + } + + #[test] + fn check_non_nullable_no_fk_passes() { + // Regular non-nullable column without FK should NOT be blocked + use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType}; + let plan = MigrationPlan { + comment: None, + created_at: None, + version: 2, + actions: vec![MigrationAction::AddColumn { + table: "post".into(), + column: Box::new(ColumnDef { + name: "user_id1".into(), + r#type: ColumnType::Simple(SimpleColumnType::Uuid), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + // Should pass — this column needs fill_with but that's handled separately + assert!(check_non_nullable_fk_add_columns(&plan).is_ok()); + } + #[test] fn test_parse_fill_with_args() { let args = vec![ From b63c77ce58c420250545cb0da09a428a7627570f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 3 Feb 2026 00:09:26 +0900 Subject: [PATCH 16/17] Add log --- .changepacks/changepack_log_gBaId5Bxp-cRI-sa7EJH0.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changepacks/changepack_log_gBaId5Bxp-cRI-sa7EJH0.json diff --git a/.changepacks/changepack_log_gBaId5Bxp-cRI-sa7EJH0.json b/.changepacks/changepack_log_gBaId5Bxp-cRI-sa7EJH0.json new file mode 100644 index 0000000..3cbeaf7 --- /dev/null +++ b/.changepacks/changepack_log_gBaId5Bxp-cRI-sa7EJH0.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch"},"note":"Fix enum diff logic, sqlite issue, Add verbose option on migration, Block revision command with add required column with fk","date":"2026-02-02T15:09:19.543239600Z"} \ No newline at end of file From bbd0f1c38b234a3a0cc2a0668c0ea3531211720d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 3 Feb 2026 00:49:48 +0900 Subject: [PATCH 17/17] Add testcase --- crates/vespertide-macro/src/lib.rs | 138 +++++++++++ crates/vespertide-planner/src/diff.rs | 105 ++++++++ .../src/sql/add_constraint.rs | 231 ++++++++++++++++++ crates/vespertide-query/src/sql/helpers.rs | 67 +++++ .../src/sql/remove_constraint.rs | 62 +++++ 5 files changed, 603 insertions(+) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 99f3c90..e85b5b7 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -864,6 +864,144 @@ mod tests { } } + #[test] + fn test_build_migration_block_verbose_create_table() { + let migration = MigrationPlan { + version: 1, + comment: Some("initial setup".into()), + created_at: None, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![test_column("id")], + constraints: vec![], + }], + }; + + let mut baseline = Vec::new(); + let result = build_migration_block(&migration, &mut baseline, true); + + assert!(result.is_ok()); + let block_str = result.unwrap().to_string(); + + // Verbose mode should contain eprintln statements with action descriptions + assert!(block_str.contains("vespertide")); + assert!(block_str.contains("Action")); + assert!(block_str.contains("version < 1u32")); + } + + #[test] + fn test_build_migration_block_verbose_multiple_actions() { + let migration = MigrationPlan { + version: 1, + comment: None, + created_at: None, + actions: vec![ + MigrationAction::CreateTable { + table: "users".into(), + columns: vec![test_column("id")], + constraints: vec![], + }, + MigrationAction::CreateTable { + table: "posts".into(), + columns: vec![test_column("id")], + constraints: vec![], + }, + ], + }; + + let mut baseline = Vec::new(); + let result = build_migration_block(&migration, &mut baseline, true); + + assert!(result.is_ok()); + let block_str = result.unwrap().to_string(); + + // Should have action numbering for both actions + assert!(block_str.contains("Action")); + assert_eq!(baseline.len(), 2); + } + + #[test] + fn test_build_migration_block_verbose_add_column() { + // Create table first + let create = MigrationPlan { + version: 1, + comment: None, + created_at: None, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![test_column("id")], + constraints: vec![], + }], + }; + let mut baseline = Vec::new(); + let _ = build_migration_block(&create, &mut baseline, true); + + // Add column in verbose mode + let add_col = MigrationPlan { + version: 2, + comment: Some("add email".into()), + created_at: None, + actions: vec![MigrationAction::AddColumn { + table: "users".into(), + column: Box::new(ColumnDef { + name: "email".into(), + r#type: ColumnType::Simple(SimpleColumnType::Text), + nullable: true, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }), + fill_with: None, + }], + }; + + let result = build_migration_block(&add_col, &mut baseline, true); + assert!(result.is_ok()); + let block_str = result.unwrap().to_string(); + assert!(block_str.contains("vespertide")); + assert!(block_str.contains("version < 2u32")); + } + + #[test] + fn test_generate_migration_code_verbose() { + let pool: Expr = syn::parse_str("db_pool").unwrap(); + let version_table = "test_versions"; + + let migration = MigrationPlan { + version: 1, + comment: None, + created_at: None, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![test_column("id")], + constraints: vec![], + }], + }; + + let mut baseline = Vec::new(); + let block = build_migration_block(&migration, &mut baseline, true).unwrap(); + + let generated = generate_migration_code(&pool, version_table, vec![block], true); + let generated_str = generated.to_string(); + + // Verbose mode should include current version eprintln + assert!(generated_str.contains("Current database version")); + assert!(generated_str.contains("async")); + } + + #[test] + fn test_macro_parsing_verbose_flag() { + // Test parsing the "verbose" keyword + let input: proc_macro2::TokenStream = "pool, verbose".parse().unwrap(); + let output = vespertide_migration_impl(input); + let output_str = output.to_string(); + // Should produce output (either success or migration loading error) + assert!(!output_str.is_empty()); + } + #[test] fn test_vespertide_migration_impl_with_migrations() { use std::fs; diff --git a/crates/vespertide-planner/src/diff.rs b/crates/vespertide-planner/src/diff.rs index 04ff1af..d5bfc70 100644 --- a/crates/vespertide-planner/src/diff.rs +++ b/crates/vespertide-planner/src/diff.rs @@ -4298,6 +4298,111 @@ mod tests { } } + #[test] + fn test_sort_enum_default_dependencies_swaps_when_old_default_removed() { + // Scenario: enum column "status" changes from [active, pending, done] → [active, done] + // and default changes from 'pending' → 'active'. + // The ModifyColumnDefault must come BEFORE ModifyColumnType. + use vespertide_core::{ComplexColumnType, DefaultValue, EnumValues}; + + let enum_type_old = ColumnType::Complex(ComplexColumnType::Enum { + name: "status_enum".into(), + values: EnumValues::String(vec!["active".into(), "pending".into(), "done".into()]), + }); + let enum_type_new = ColumnType::Complex(ComplexColumnType::Enum { + name: "status_enum".into(), + values: EnumValues::String(vec!["active".into(), "done".into()]), + }); + + let from = vec![table( + "orders", + vec![{ + let mut c = col("status", enum_type_old); + c.default = Some(DefaultValue::String("'pending'".into())); + c + }], + vec![], + )]; + let to = vec![table( + "orders", + vec![{ + let mut c = col("status", enum_type_new); + c.default = Some(DefaultValue::String("'active'".into())); + c + }], + vec![], + )]; + + let plan = diff_schemas(&from, &to).unwrap(); + + // Should have both ModifyColumnDefault and ModifyColumnType + let has_modify_default = plan + .actions + .iter() + .any(|a| matches!(a, MigrationAction::ModifyColumnDefault { .. })); + let has_modify_type = plan + .actions + .iter() + .any(|a| matches!(a, MigrationAction::ModifyColumnType { .. })); + assert!(has_modify_default, "Should have ModifyColumnDefault"); + assert!(has_modify_type, "Should have ModifyColumnType"); + + // ModifyColumnDefault should come BEFORE ModifyColumnType + let default_idx = plan + .actions + .iter() + .position(|a| matches!(a, MigrationAction::ModifyColumnDefault { .. })) + .unwrap(); + let type_idx = plan + .actions + .iter() + .position(|a| matches!(a, MigrationAction::ModifyColumnType { .. })) + .unwrap(); + assert!( + default_idx < type_idx, + "ModifyColumnDefault (idx={}) must come before ModifyColumnType (idx={})", + default_idx, + type_idx + ); + } + + #[test] + fn test_delete_column_from_existing_table() { + // Simple column deletion to cover diff.rs line 339 + let from = vec![table( + "users", + vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + col("name", ColumnType::Simple(SimpleColumnType::Text)), + col("age", ColumnType::Simple(SimpleColumnType::Integer)), + ], + vec![], + )]; + let to = vec![table( + "users", + vec![ + col("id", ColumnType::Simple(SimpleColumnType::Integer)), + // name and age deleted + ], + vec![], + )]; + + let plan = diff_schemas(&from, &to).unwrap(); + + let delete_cols: Vec<&str> = plan + .actions + .iter() + .filter_map(|a| match a { + MigrationAction::DeleteColumn { column, .. } => Some(column.as_str()), + _ => None, + }) + .collect(); + + assert_eq!(delete_cols.len(), 2); + assert!(delete_cols.contains(&"name")); + assert!(delete_cols.contains(&"age")); + } + mod constraint_removal_on_deleted_columns { use super::*; diff --git a/crates/vespertide-query/src/sql/add_constraint.rs b/crates/vespertide-query/src/sql/add_constraint.rs index a81ca08..1dbf3c7 100644 --- a/crates/vespertide-query/src/sql/add_constraint.rs +++ b/crates/vespertide-query/src/sql/add_constraint.rs @@ -1088,6 +1088,237 @@ mod tests { assert!(sql.contains("CREATE TABLE")); } + #[test] + fn test_add_constraint_composite_primary_key_postgres() { + let constraint = TableConstraint::PrimaryKey { + columns: vec!["user_id".into(), "role_id".into()], + auto_increment: false, + }; + let current_schema = vec![TableDef { + name: "user_roles".into(), + description: None, + columns: vec![ + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "role_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + let result = build_add_constraint( + &DatabaseBackend::Postgres, + "user_roles", + &constraint, + ¤t_schema, + &[], + ) + .unwrap(); + let sql = result[0].build(DatabaseBackend::Postgres); + assert!(sql.contains("ADD PRIMARY KEY")); + assert!(sql.contains("\"user_id\"")); + assert!(sql.contains("\"role_id\"")); + } + + #[test] + fn test_add_constraint_composite_primary_key_mysql() { + let constraint = TableConstraint::PrimaryKey { + columns: vec!["user_id".into(), "role_id".into()], + auto_increment: false, + }; + let current_schema = vec![TableDef { + name: "user_roles".into(), + description: None, + columns: vec![ + ColumnDef { + name: "user_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ColumnDef { + name: "role_id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }, + ], + constraints: vec![], + }]; + let result = build_add_constraint( + &DatabaseBackend::MySql, + "user_roles", + &constraint, + ¤t_schema, + &[], + ) + .unwrap(); + let sql = result[0].build(DatabaseBackend::MySql); + assert!(sql.contains("ADD PRIMARY KEY")); + assert!(sql.contains("`user_id`")); + assert!(sql.contains("`role_id`")); + } + + #[test] + fn test_constraints_overlap_primary_key_same_columns() { + let a = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let b = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: true, + }; + assert!(constraints_overlap(&a, &b)); + } + + #[test] + fn test_constraints_overlap_primary_key_different_columns() { + let a = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let b = TableConstraint::PrimaryKey { + columns: vec!["uid".into()], + auto_increment: false, + }; + assert!(!constraints_overlap(&a, &b)); + } + + #[test] + fn test_constraints_overlap_check_same() { + let a = TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }; + let b = TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }; + assert!(constraints_overlap(&a, &b)); + } + + #[test] + fn test_constraints_overlap_check_different_name() { + let a = TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }; + let b = TableConstraint::Check { + name: "chk_age2".into(), + expr: "age > 0".into(), + }; + assert!(!constraints_overlap(&a, &b)); + } + + #[test] + fn test_constraints_overlap_check_different_expr() { + let a = TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 0".into(), + }; + let b = TableConstraint::Check { + name: "chk_age".into(), + expr: "age > 10".into(), + }; + assert!(!constraints_overlap(&a, &b)); + } + + #[test] + fn test_constraints_overlap_different_variants() { + let a = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let b = TableConstraint::Check { + name: "chk".into(), + expr: "id > 0".into(), + }; + assert!(!constraints_overlap(&a, &b)); + } + + #[test] + fn test_constraints_overlap_fk_same_columns() { + let a = TableConstraint::ForeignKey { + name: None, + columns: vec!["user_id".into()], + ref_table: "users".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }; + let b = TableConstraint::ForeignKey { + name: Some("fk".into()), + columns: vec!["user_id".into()], + ref_table: "other".into(), + ref_columns: vec!["oid".into()], + on_delete: Some(ReferenceAction::Cascade), + on_update: None, + }; + assert!(constraints_overlap(&a, &b)); + } + + #[test] + fn test_merge_constraint_replaces_overlapping() { + let existing = vec![ + TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }, + TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }, + ]; + let new_pk = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: true, + }; + let result = merge_constraint(&existing, &new_pk); + assert_eq!(result.len(), 2); // replaced, not added + } + + #[test] + fn test_merge_constraint_appends_non_overlapping() { + let existing = vec![TableConstraint::Index { + name: None, + columns: vec!["email".into()], + }]; + let new_pk = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let result = merge_constraint(&existing, &new_pk); + assert_eq!(result.len(), 2); // appended + } + #[test] fn test_extract_check_clauses_with_mixed_constraints() { // Test that extract_check_clauses filters out non-Check constraints diff --git a/crates/vespertide-query/src/sql/helpers.rs b/crates/vespertide-query/src/sql/helpers.rs index 3646615..7ae10cb 100644 --- a/crates/vespertide-query/src/sql/helpers.rs +++ b/crates/vespertide-query/src/sql/helpers.rs @@ -779,4 +779,71 @@ mod tests { fn test_needs_quoting(#[case] input: &str, #[case] expected: bool) { assert_eq!(needs_quoting(input), expected); } + + #[test] + fn test_recreate_indexes_after_rebuild_skips_pending() { + use vespertide_core::TableConstraint; + let idx1 = TableConstraint::Index { + name: Some("idx_a".into()), + columns: vec!["a".into()], + }; + let idx2 = TableConstraint::Index { + name: Some("idx_b".into()), + columns: vec!["b".into()], + }; + let uq1 = TableConstraint::Unique { + name: Some("uq_c".into()), + columns: vec!["c".into()], + }; + + // All three in table constraints, but idx1 and uq1 are pending + let constraints = vec![idx1.clone(), idx2.clone(), uq1.clone()]; + let pending = vec![idx1.clone(), uq1.clone()]; + + let queries = recreate_indexes_after_rebuild("t", &constraints, &pending); + // Only idx_b should be recreated + assert_eq!(queries.len(), 1); + let sql = queries[0].build(DatabaseBackend::Sqlite); + assert!(sql.contains("idx_b")); + } + + #[test] + fn test_recreate_indexes_after_rebuild_no_pending() { + use vespertide_core::TableConstraint; + let idx = TableConstraint::Index { + name: Some("idx_a".into()), + columns: vec!["a".into()], + }; + let uq = TableConstraint::Unique { + name: Some("uq_b".into()), + columns: vec!["b".into()], + }; + + let queries = recreate_indexes_after_rebuild("t", &[idx, uq], &[]); + assert_eq!(queries.len(), 2); + } + + #[test] + fn test_recreate_indexes_after_rebuild_skips_non_index_constraints() { + use vespertide_core::TableConstraint; + let pk = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let fk = TableConstraint::ForeignKey { + name: None, + columns: vec!["uid".into()], + ref_table: "u".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + }; + let chk = TableConstraint::Check { + name: "chk".into(), + expr: "id > 0".into(), + }; + + let queries = recreate_indexes_after_rebuild("t", &[pk, fk, chk], &[]); + assert_eq!(queries.len(), 0); + } } diff --git a/crates/vespertide-query/src/sql/remove_constraint.rs b/crates/vespertide-query/src/sql/remove_constraint.rs index e15d95b..71ca8b5 100644 --- a/crates/vespertide-query/src/sql/remove_constraint.rs +++ b/crates/vespertide-query/src/sql/remove_constraint.rs @@ -1440,6 +1440,68 @@ mod tests { }); } + #[test] + fn test_remove_constraint_primary_key_postgres_direct() { + // Direct non-rstest coverage for PK drop on Postgres (lines 53-54) + let constraint = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let schema = vec![TableDef { + name: "orders".into(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![constraint.clone()], + }]; + let result = + build_remove_constraint(&DatabaseBackend::Postgres, "orders", &constraint, &schema) + .unwrap(); + assert_eq!(result.len(), 1); + let sql = result[0].build(DatabaseBackend::Postgres); + assert!(sql.contains("ALTER TABLE \"orders\" DROP CONSTRAINT \"orders_pkey\"")); + } + + #[test] + fn test_remove_constraint_primary_key_mysql_direct() { + // Direct non-rstest coverage for PK drop on MySQL (lines 53-54) + let constraint = TableConstraint::PrimaryKey { + columns: vec!["id".into()], + auto_increment: false, + }; + let schema = vec![TableDef { + name: "orders".into(), + description: None, + columns: vec![ColumnDef { + name: "id".into(), + r#type: ColumnType::Simple(SimpleColumnType::Integer), + nullable: false, + default: None, + comment: None, + primary_key: None, + unique: None, + index: None, + foreign_key: None, + }], + constraints: vec![constraint.clone()], + }]; + let result = + build_remove_constraint(&DatabaseBackend::MySql, "orders", &constraint, &schema) + .unwrap(); + assert_eq!(result.len(), 1); + let sql = result[0].build(DatabaseBackend::MySql); + assert!(sql.contains("ALTER TABLE `orders` DROP PRIMARY KEY")); + } + #[rstest] #[case::remove_index_with_custom_inline_name_postgres(DatabaseBackend::Postgres)] #[case::remove_index_with_custom_inline_name_mysql(DatabaseBackend::MySql)]