use sea_query::{Alias, Index};
use vespertide_core::{ColumnType, ComplexColumnType, TableConstraint, TableDef};
use self::direct::build_direct_delete_column;
use self::sqlite_rebuild::build_delete_column_sqlite_temp_table;
use super::types::{BuiltQuery, DatabaseBackend};
mod direct;
mod sqlite_rebuild;
pub fn build_delete_column(
backend: DatabaseBackend,
table: &str,
column: &str,
column_type: Option<&ColumnType>,
current_schema: &[TableDef],
pending_constraints: &[TableConstraint],
) -> Vec<BuiltQuery> {
let mut stmts = Vec::new();
if backend == DatabaseBackend::Sqlite
&& let Some(table_def) = current_schema.iter().find(|t| t.name == table)
&& let Some(sqlite_queries) = sqlite_constraint_handling(
table,
column,
table_def,
column_type,
pending_constraints,
&mut stmts,
)
{
return sqlite_queries;
}
stmts.extend(build_direct_delete_column(table, column, column_type));
stmts
}
fn sqlite_constraint_handling(
table: &str,
column: &str,
table_def: &TableDef,
column_type: Option<&ColumnType>,
pending_constraints: &[TableConstraint],
stmts: &mut Vec<BuiltQuery>,
) -> Option<Vec<BuiltQuery>> {
if let Some(col_def) = table_def.columns.iter().find(|c| c.name == column)
&& let ColumnType::Complex(ComplexColumnType::Enum { .. }) = &col_def.r#type
{
return Some(build_delete_column_sqlite_temp_table(
table,
column,
table_def,
column_type,
pending_constraints,
));
}
for constraint in &table_def.constraints {
match constraint {
TableConstraint::Check { expr, .. } => {
if expr.contains(&format!("\"{column}\"")) || expr.contains(column) {
return Some(build_delete_column_sqlite_temp_table(
table,
column,
table_def,
column_type,
pending_constraints,
));
}
}
_ if !constraint.columns().iter().any(|c| c == column) => {}
TableConstraint::ForeignKey { .. } | TableConstraint::PrimaryKey { .. } => {
return Some(build_delete_column_sqlite_temp_table(
table,
column,
table_def,
column_type,
pending_constraints,
));
}
TableConstraint::Unique { name, columns, .. } => {
let index_name = vespertide_naming::build_unique_constraint_name(
table,
columns,
name.as_deref(),
);
let drop_idx = Index::drop()
.name(&index_name)
.table(Alias::new(table))
.to_owned();
stmts.push(BuiltQuery::DropIndex(Box::new(drop_idx)));
}
TableConstraint::Index { name, columns } => {
let index_name =
vespertide_naming::build_index_name(table, columns, name.as_deref());
let drop_idx = Index::drop()
.name(&index_name)
.table(Alias::new(table))
.to_owned();
stmts.push(BuiltQuery::DropIndex(Box::new(drop_idx)));
}
_ => {
unreachable!("TableConstraint is #[non_exhaustive]; all variants are matched above")
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sql::types::DatabaseBackend;
use crate::test_support::col;
use insta::{assert_snapshot, with_settings};
use rstest::rstest;
use vespertide_core::{ComplexColumnType, SimpleColumnType};
#[rstest]
#[case::delete_column_postgres(
"delete_column_postgres",
DatabaseBackend::Postgres,
&["ALTER TABLE \"users\" DROP COLUMN \"email\""]
)]
#[case::delete_column_mysql(
"delete_column_mysql",
DatabaseBackend::MySql,
&["ALTER TABLE `users` DROP COLUMN `email`"]
)]
#[case::delete_column_sqlite(
"delete_column_sqlite",
DatabaseBackend::Sqlite,
&["ALTER TABLE \"users\" DROP COLUMN \"email\""]
)]
fn test_delete_column(
#[case] title: &str,
#[case] backend: DatabaseBackend,
#[case] expected: &[&str],
) {
let result = build_delete_column(backend, "users", "email", None, &[], &[]);
let sql = result[0].build(backend);
for exp in expected {
assert!(
sql.contains(exp),
"Expected SQL to contain '{exp}', got: {sql}"
);
}
with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_{}", title) }, {
assert_snapshot!(sql);
});
}
#[test]
fn test_delete_enum_column_postgres() {
use vespertide_core::EnumValues;
let enum_type = ColumnType::Complex(ComplexColumnType::Enum {
name: "status".into(),
values: EnumValues::String(vec!["active".into(), "inactive".into()]),
});
let result = build_delete_column(
DatabaseBackend::Postgres,
"users",
"status",
Some(&enum_type),
&[],
&[],
);
assert_eq!(result.len(), 2);
let alter_sql = result[0].build(DatabaseBackend::Postgres);
assert!(alter_sql.contains("DROP COLUMN"));
let drop_type_sql = result[1].build(DatabaseBackend::Postgres);
assert!(drop_type_sql.contains("DROP TYPE \"users_status\""));
let drop_type_mysql = result[1].build(DatabaseBackend::MySql);
assert!(drop_type_mysql.is_empty());
}
#[test]
fn test_delete_non_enum_column_no_drop_type() {
let text_type = ColumnType::Simple(SimpleColumnType::Text);
let result = build_delete_column(
DatabaseBackend::Postgres,
"users",
"name",
Some(&text_type),
&[],
&[],
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_delete_column_sqlite_drops_unique_constraint_first() {
let schema = vec![TableDef {
name: "gift".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("gift_code", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::Unique {
name: None,
columns: vec!["gift_code".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"gift",
"gift_code",
None,
&schema,
&[],
);
assert_eq!(result.len(), 2);
let drop_index_sql = result[0].build(DatabaseBackend::Sqlite);
assert!(
drop_index_sql.contains("DROP INDEX"),
"Expected DROP INDEX, got: {drop_index_sql}"
);
assert!(
drop_index_sql.contains("uq_gift__gift_code"),
"Expected index name uq_gift__gift_code, got: {drop_index_sql}"
);
let drop_column_sql = result[1].build(DatabaseBackend::Sqlite);
assert!(
drop_column_sql.contains("DROP COLUMN"),
"Expected DROP COLUMN, got: {drop_column_sql}"
);
}
#[test]
fn test_delete_column_sqlite_drops_index_constraint_first() {
let schema = vec![TableDef {
name: "users".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("email", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::Index {
name: None,
columns: vec!["email".into()],
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"users",
"email",
None,
&schema,
&[],
);
assert_eq!(result.len(), 2);
let drop_index_sql = result[0].build(DatabaseBackend::Sqlite);
assert!(drop_index_sql.contains("DROP INDEX"));
assert!(drop_index_sql.contains("ix_users__email"));
let drop_column_sql = result[1].build(DatabaseBackend::Sqlite);
assert!(drop_column_sql.contains("DROP COLUMN"));
}
#[test]
fn test_delete_column_postgres_does_not_drop_constraints() {
let schema = vec![TableDef {
name: "gift".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("gift_code", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::Unique {
name: None,
columns: vec!["gift_code".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
}],
}];
let result = build_delete_column(
DatabaseBackend::Postgres,
"gift",
"gift_code",
None,
&schema,
&[],
);
assert_eq!(result.len(), 1);
let drop_column_sql = result[0].build(DatabaseBackend::Postgres);
assert!(drop_column_sql.contains("DROP COLUMN"));
}
#[test]
fn test_delete_column_sqlite_with_named_unique_constraint() {
let schema = vec![TableDef {
name: "gift".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("gift_code", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::Unique {
name: Some("gift_code".into()),
columns: vec!["gift_code".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"gift",
"gift_code",
None,
&schema,
&[],
);
assert_eq!(result.len(), 2);
let drop_index_sql = result[0].build(DatabaseBackend::Sqlite);
assert!(
drop_index_sql.contains("uq_gift__gift_code"),
"Expected uq_gift__gift_code, got: {drop_index_sql}"
);
}
#[test]
fn test_delete_column_sqlite_with_fk_uses_temp_table() {
let schema = vec![TableDef {
name: "gift".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("sender_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col("message", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::ForeignKey {
name: None,
columns: vec!["sender_id".into()],
ref_table: "user".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"gift",
"sender_id",
None,
&schema,
&[],
);
assert!(
result.len() >= 4,
"Expected at least 4 statements for temp table approach, got: {}",
result.len()
);
let all_sql: Vec<String> = result
.iter()
.map(|q| q.build(DatabaseBackend::Sqlite))
.collect();
let combined_sql = all_sql.join("\n");
assert!(
combined_sql.contains("CREATE TABLE") && combined_sql.contains("gift_temp"),
"Expected CREATE TABLE gift_temp, got: {combined_sql}"
);
assert!(
!combined_sql.contains("\"sender_id\"") || combined_sql.contains("DROP TABLE"),
"New table should not contain sender_id column"
);
assert!(
combined_sql.contains("INSERT INTO"),
"Expected INSERT INTO for data copy, got: {combined_sql}"
);
assert!(
combined_sql.contains("DROP TABLE") && combined_sql.contains("\"gift\""),
"Expected DROP TABLE gift, got: {combined_sql}"
);
assert!(
combined_sql.contains("RENAME"),
"Expected RENAME for temp table, got: {combined_sql}"
);
}
#[test]
fn test_delete_column_sqlite_with_fk_preserves_other_columns() {
let schema = vec![TableDef {
name: "gift".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("sender_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col("receiver_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col("message", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![
TableConstraint::ForeignKey {
name: None,
columns: vec!["sender_id".into()],
ref_table: "user".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
},
TableConstraint::Index {
name: None,
columns: vec!["receiver_id".into()],
},
],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"gift",
"sender_id",
None,
&schema,
&[],
);
let all_sql: Vec<String> = result
.iter()
.map(|q| q.build(DatabaseBackend::Sqlite))
.collect();
let combined_sql = all_sql.join("\n");
assert!(combined_sql.contains("\"id\""), "Should preserve id column");
assert!(
combined_sql.contains("\"receiver_id\""),
"Should preserve receiver_id column"
);
assert!(
combined_sql.contains("\"message\""),
"Should preserve message column"
);
assert!(
combined_sql.contains("CREATE INDEX") && combined_sql.contains("ix_gift__receiver_id"),
"Should recreate index on receiver_id, got: {combined_sql}"
);
}
#[test]
fn test_delete_column_postgres_with_fk_does_not_use_temp_table() {
let schema = vec![TableDef {
name: "gift".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("sender_id", ColumnType::Simple(SimpleColumnType::BigInt)),
],
constraints: vec![TableConstraint::ForeignKey {
name: None,
columns: vec!["sender_id".into()],
ref_table: "user".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
}],
}];
let result = build_delete_column(
DatabaseBackend::Postgres,
"gift",
"sender_id",
None,
&schema,
&[],
);
assert_eq!(
result.len(),
1,
"PostgreSQL should only have 1 statement, got: {}",
result.len()
);
let sql = result[0].build(DatabaseBackend::Postgres);
assert!(
sql.contains("DROP COLUMN"),
"Expected DROP COLUMN, got: {sql}"
);
assert!(
!sql.contains("gift_temp"),
"PostgreSQL should not use temp table"
);
}
#[test]
fn test_delete_column_sqlite_with_pk_uses_temp_table() {
let schema = vec![TableDef {
name: "order_items".into(),
description: None,
columns: vec![
col("order_id", ColumnType::Simple(SimpleColumnType::Integer)),
col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
col("quantity", ColumnType::Simple(SimpleColumnType::Integer)),
],
constraints: vec![TableConstraint::PrimaryKey {
auto_increment: false,
columns: vec!["order_id".into(), "product_id".into()],
strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"order_items",
"product_id",
None,
&schema,
&[],
);
assert!(
result.len() >= 4,
"Expected at least 4 statements for temp table approach, got: {}",
result.len()
);
let all_sql: Vec<String> = result
.iter()
.map(|q| q.build(DatabaseBackend::Sqlite))
.collect();
let combined_sql = all_sql.join("\n");
assert!(
combined_sql.contains("order_items_temp"),
"Should use temp table approach for PK column deletion"
);
}
#[test]
fn test_delete_column_sqlite_unique_on_different_column_not_dropped() {
let schema = vec![TableDef {
name: "users".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("email", ColumnType::Simple(SimpleColumnType::Text)),
col("nickname", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![
TableConstraint::Unique {
name: None,
columns: vec!["email".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
},
],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"users",
"nickname",
None,
&schema,
&[],
);
assert_eq!(
result.len(),
1,
"Should not drop UNIQUE on email when deleting nickname, got: {} statements",
result.len()
);
let sql = result[0].build(DatabaseBackend::Sqlite);
assert!(
sql.contains("DROP COLUMN"),
"Expected DROP COLUMN, got: {sql}"
);
assert!(
!sql.contains("DROP INDEX"),
"Should NOT drop the email UNIQUE constraint when deleting nickname"
);
}
#[test]
fn test_delete_column_sqlite_temp_table_filters_constraints_correctly() {
let schema = vec![TableDef {
name: "orders".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col("status", ColumnType::Simple(SimpleColumnType::Text)),
col(
"created_at",
ColumnType::Simple(SimpleColumnType::Timestamp),
),
],
constraints: vec![
TableConstraint::ForeignKey {
name: None,
columns: vec!["user_id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
},
TableConstraint::Index {
name: None,
columns: vec!["created_at".into()],
},
TableConstraint::ForeignKey {
name: None,
columns: vec!["status".into()],
ref_table: "statuses".into(),
ref_columns: vec!["code".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
},
],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"orders",
"user_id",
None,
&schema,
&[],
);
let all_sql: Vec<String> = result
.iter()
.map(|q| q.build(DatabaseBackend::Sqlite))
.collect();
let combined_sql = all_sql.join("\n");
assert!(
combined_sql.contains("orders_temp"),
"Should use temp table approach for FK column deletion"
);
assert!(
combined_sql.contains("ix_orders__created_at"),
"Index on created_at should be recreated, got: {combined_sql}"
);
assert!(
combined_sql.contains("REFERENCES \"statuses\""),
"FK on status should be preserved, got: {combined_sql}"
);
let fk_patterns = combined_sql.matches("REFERENCES").count();
assert_eq!(
fk_patterns, 1,
"Only the FK on status should exist (not the one on user_id), got: {combined_sql}"
);
}
fn build_sql_snapshot(result: &[BuiltQuery], backend: DatabaseBackend) -> String {
result
.iter()
.map(|q| q.build(backend))
.collect::<Vec<_>>()
.join(";\n")
}
#[rstest]
#[case::postgres("postgres", DatabaseBackend::Postgres)]
#[case::mysql("mysql", DatabaseBackend::MySql)]
#[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
fn test_delete_column_with_unique_constraint(
#[case] title: &str,
#[case] backend: DatabaseBackend,
) {
let schema = vec![TableDef {
name: "users".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("email", ColumnType::Simple(SimpleColumnType::Text)),
col("name", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::Unique {
name: None,
columns: vec!["email".into()],
strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
keep: vespertide_core::KeepPolicy::First,
},
}],
}];
let result = build_delete_column(backend, "users", "email", None, &schema, &[]);
let sql = build_sql_snapshot(&result, backend);
with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_unique_{}", title) }, {
assert_snapshot!(sql);
});
}
#[rstest]
#[case::postgres("postgres", DatabaseBackend::Postgres)]
#[case::mysql("mysql", DatabaseBackend::MySql)]
#[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
fn test_delete_column_with_index_constraint(
#[case] title: &str,
#[case] backend: DatabaseBackend,
) {
let schema = vec![TableDef {
name: "posts".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col(
"created_at",
ColumnType::Simple(SimpleColumnType::Timestamp),
),
col("title", ColumnType::Simple(SimpleColumnType::Text)),
],
constraints: vec![TableConstraint::Index {
name: None,
columns: vec!["created_at".into()],
}],
}];
let result = build_delete_column(backend, "posts", "created_at", None, &schema, &[]);
let sql = build_sql_snapshot(&result, backend);
with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_index_{}", title) }, {
assert_snapshot!(sql);
});
}
#[rstest]
#[case::postgres("postgres", DatabaseBackend::Postgres)]
#[case::mysql("mysql", DatabaseBackend::MySql)]
#[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
fn test_delete_column_with_fk_constraint(
#[case] title: &str,
#[case] backend: DatabaseBackend,
) {
let schema = vec![TableDef {
name: "orders".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col("total", ColumnType::Simple(SimpleColumnType::Integer)),
],
constraints: vec![TableConstraint::ForeignKey {
name: None,
columns: vec!["user_id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
}],
}];
let result = build_delete_column(backend, "orders", "user_id", None, &schema, &[]);
let sql = build_sql_snapshot(&result, backend);
with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_fk_{}", title) }, {
assert_snapshot!(sql);
});
}
#[rstest]
#[case::postgres("postgres", DatabaseBackend::Postgres)]
#[case::mysql("mysql", DatabaseBackend::MySql)]
#[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
fn test_delete_column_with_pk_constraint(
#[case] title: &str,
#[case] backend: DatabaseBackend,
) {
let schema = vec![TableDef {
name: "order_items".into(),
description: None,
columns: vec![
col("order_id", ColumnType::Simple(SimpleColumnType::Integer)),
col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
col("quantity", ColumnType::Simple(SimpleColumnType::Integer)),
],
constraints: vec![TableConstraint::PrimaryKey {
auto_increment: false,
columns: vec!["order_id".into(), "product_id".into()],
strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
}],
}];
let result = build_delete_column(backend, "order_items", "product_id", None, &schema, &[]);
let sql = build_sql_snapshot(&result, backend);
with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_pk_{}", title) }, {
assert_snapshot!(sql);
});
}
#[rstest]
#[case::postgres("postgres", DatabaseBackend::Postgres)]
#[case::mysql("mysql", DatabaseBackend::MySql)]
#[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
fn test_delete_column_with_fk_and_index_constraints(
#[case] title: &str,
#[case] backend: DatabaseBackend,
) {
let schema = vec![TableDef {
name: "orders".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col(
"created_at",
ColumnType::Simple(SimpleColumnType::Timestamp),
),
col("total", ColumnType::Simple(SimpleColumnType::Integer)),
],
constraints: vec![
TableConstraint::ForeignKey {
name: None,
columns: vec!["user_id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
},
TableConstraint::Index {
name: None,
columns: vec!["created_at".into()],
},
],
}];
let result = build_delete_column(backend, "orders", "user_id", None, &schema, &[]);
let sql = build_sql_snapshot(&result, backend);
with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_fk_and_index_{}", title) }, {
assert_snapshot!(sql);
});
}
#[test]
fn test_delete_column_sqlite_temp_table_with_enum_column() {
use vespertide_core::EnumValues;
let enum_type = ColumnType::Complex(ComplexColumnType::Enum {
name: "order_status".into(),
values: EnumValues::String(vec![
"pending".into(),
"shipped".into(),
"delivered".into(),
]),
});
let schema = vec![TableDef {
name: "orders".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
col("status", enum_type.clone()),
],
constraints: vec![TableConstraint::ForeignKey {
name: None,
columns: vec!["user_id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"orders",
"user_id",
Some(&enum_type),
&schema,
&[],
);
assert!(
result.len() >= 4,
"Expected at least 4 statements for temp table approach, got: {}",
result.len()
);
let all_sql: Vec<String> = result
.iter()
.map(|q| q.build(DatabaseBackend::Sqlite))
.collect();
let combined_sql = all_sql.join("\n");
assert!(
combined_sql.contains("orders_temp"),
"Should use temp table approach"
);
let last_stmt = result.last().unwrap();
let last_sql = last_stmt.build(DatabaseBackend::Sqlite);
assert!(
last_sql.is_empty() || !last_sql.contains("DROP TYPE"),
"SQLite should not emit DROP TYPE"
);
let pg_last_sql = last_stmt.build(DatabaseBackend::Postgres);
assert!(
pg_last_sql.contains("DROP TYPE"),
"PostgreSQL should emit DROP TYPE, got: {pg_last_sql}"
);
}
#[test]
fn test_delete_column_sqlite_with_check_constraint_referencing_column() {
let schema = vec![TableDef {
name: "orders".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("amount", ColumnType::Simple(SimpleColumnType::Integer)),
],
constraints: vec![TableConstraint::Check {
name: "check_positive".into(),
expr: "amount > 0".into(),
strategy: vespertide_core::CheckViolationStrategy::default(),
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"orders",
"amount",
None,
&schema,
&[],
);
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}"
);
assert!(
!sql.contains("check_positive"),
"CHECK referencing deleted column should be removed, got: {sql}"
);
}
#[rstest]
#[case::sqlite_unquoted(DatabaseBackend::Sqlite, "age > 0")]
#[case::sqlite_quoted(DatabaseBackend::Sqlite, r#""age" > 0"#)]
#[case::postgres_unquoted(DatabaseBackend::Postgres, "age > 0")]
#[case::postgres_quoted(DatabaseBackend::Postgres, r#""age" > 0"#)]
#[case::mysql_unquoted(DatabaseBackend::MySql, "age > 0")]
#[case::mysql_quoted(DatabaseBackend::MySql, r#""age" > 0"#)]
fn delete_column_removes_check_constraint_referencing_it(
#[case] backend: DatabaseBackend,
#[case] check_expr: &str,
) {
let schema = vec![TableDef {
name: "people".into(),
description: None,
columns: vec![
col("id", ColumnType::Simple(SimpleColumnType::Integer)),
col("age", ColumnType::Simple(SimpleColumnType::Integer)),
],
constraints: vec![TableConstraint::Check {
name: "chk_age_positive".into(),
expr: check_expr.into(),
strategy: vespertide_core::CheckViolationStrategy::default(),
}],
}];
let queries = build_delete_column(backend, "people", "age", None, &schema, &[]);
let sql = queries
.iter()
.map(|q| q.build(backend))
.collect::<Vec<_>>()
.join("\n");
if backend == DatabaseBackend::Sqlite {
assert!(
!sql.contains("chk_age_positive"),
"SQLite rebuild must omit the CHECK '{check_expr}' that \
references the deleted column; got: {sql}"
);
assert!(
!sql.contains(check_expr),
"SQLite rebuild must not echo the CHECK expression \
'{check_expr}' (it references the deleted column); got: {sql}"
);
assert!(
sql.contains("people_temp"),
"expected SQLite temp-table rebuild for 'people'; got: {sql}"
);
} else {
assert!(
!queries.is_empty(),
"{backend:?}: expected DROP COLUMN output, got empty"
);
assert!(
sql.contains("DROP COLUMN"),
"{backend:?}: expected DROP COLUMN, got: {sql}"
);
}
}
#[test]
fn test_delete_column_sqlite_with_check_constraint_not_referencing_column() {
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(),
strategy: vespertide_core::CheckViolationStrategy::default(),
}],
}];
let result = build_delete_column(
DatabaseBackend::Sqlite,
"orders",
"note",
None,
&schema,
&[],
);
assert_eq!(
result.len(),
1,
"Unrelated CHECK should be skipped, got: {} statements",
result.len()
);
let sql = result[0].build(DatabaseBackend::Sqlite);
assert!(
sql.contains("DROP COLUMN"),
"Expected DROP COLUMN, got: {sql}"
);
}
}