diff --git a/sea-orm-arrow/Cargo.toml b/sea-orm-arrow/Cargo.toml index 6c450d7bb7..19910792f9 100644 --- a/sea-orm-arrow/Cargo.toml +++ b/sea-orm-arrow/Cargo.toml @@ -25,6 +25,3 @@ with-bigdecimal = ["sea-query/with-bigdecimal"] with-chrono = ["sea-query/with-chrono"] with-rust_decimal = ["sea-query/with-rust_decimal"] with-time = ["sea-query/with-time"] - -[patch.crates-io] -# sea-query = { path = "../../sea-query" } diff --git a/src/schema/builder.rs b/src/schema/builder.rs index 535c379cfc..be4382919e 100644 --- a/src/schema/builder.rs +++ b/src/schema/builder.rs @@ -1,8 +1,8 @@ use super::{Schema, TopologicalSort}; use crate::{ConnectionTrait, DbBackend, DbErr, EntityTrait, Statement}; use sea_query::{ - ForeignKeyCreateStatement, IndexCreateStatement, TableAlterStatement, TableCreateStatement, - TableName, TableRef, extension::postgres::TypeCreateStatement, + ForeignKeyCreateStatement, Index, IndexCreateStatement, IntoIden, TableAlterStatement, + TableCreateStatement, TableName, TableRef, extension::postgres::TypeCreateStatement, }; /// A schema builder that can take a registry of Entities and synchronize it with database. @@ -387,6 +387,46 @@ impl EntitySchemaInfo { db.execute(&stmt).await?; } } + if let Some(existing_table) = existing_table { + // For columns with a column-level UNIQUE constraint (#[sea_orm(unique)]) that + // already exist in the table but do not yet have a unique index, create one. + for column_def in self.table.get_columns() { + if column_def.get_column_spec().unique { + let col_name = column_def.get_column_name(); + let col_exists = existing_table + .get_columns() + .iter() + .any(|c| c.get_column_name() == col_name); + if !col_exists { + // Column is being added in this sync pass; the ALTER TABLE ADD COLUMN + // will include the UNIQUE inline, so no separate index needed. + continue; + } + let already_unique = existing_table.get_indexes().iter().any(|idx| { + if !idx.is_unique_key() { + return false; + } + let cols = idx.get_index_spec().get_column_names(); + cols.len() == 1 && cols[0] == col_name + }); + if !already_unique { + let table_name = + self.table.get_table_name().expect("table must have a name"); + let tbl_str = table_name.sea_orm_table().to_string(); + let table_ref = table_name.clone(); + db.execute( + Index::create() + .name(format!("idx-{tbl_str}-{col_name}")) + .table(table_ref) + .col(col_name.into_iden()) + .unique() + .if_not_exists(), + ) + .await?; + } + } + } + } if let Some(existing_table) = existing_table { // find all unique keys from existing table // if it no longer exist in new schema, drop it @@ -401,6 +441,23 @@ impl EntitySchemaInfo { break; } } + // Also check if the unique index corresponds to a column-level UNIQUE + // constraint (from #[sea_orm(unique)]). These are embedded in the CREATE + // TABLE column definition and not tracked in self.indexes, so we must not + // try to drop them during sync. + if !has_index { + let index_cols = existing_index.get_index_spec().get_column_names(); + if index_cols.len() == 1 { + for column_def in self.table.get_columns() { + if column_def.get_column_name() == index_cols[0] + && column_def.get_column_spec().unique + { + has_index = true; + break; + } + } + } + } if !has_index { if let Some(drop_existing) = existing_index.get_index_spec().get_name() { db.execute(sea_query::Index::drop().name(drop_existing)) diff --git a/tests/schema_sync_tests.rs b/tests/schema_sync_tests.rs new file mode 100644 index 0000000000..ddffc413c7 --- /dev/null +++ b/tests/schema_sync_tests.rs @@ -0,0 +1,226 @@ +#![allow(unused_imports, dead_code)] + +pub mod common; + +use crate::common::TestContext; +use sea_orm::{ + DatabaseConnection, DbErr, + entity::*, + query::*, + sea_query::{Condition, Expr, Query}, +}; + +// Scenario 1: table is first synced with a `#[sea_orm(unique)]` column already +// present. Repeated syncs must not drop the column-level unique constraint. +mod item_v1 { + use sea_orm::entity::prelude::*; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "sync_item")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub name: String, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +// Scenario 2a: initial version of the table — no unique column yet. +mod product_v1 { + use sea_orm::entity::prelude::*; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "sync_product")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +// Scenario 2b: updated version — a unique column is added. +mod product_v2 { + use sea_orm::entity::prelude::*; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "sync_product")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub sku: String, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +// Scenario 3a: initial version — column exists without UNIQUE. +mod user_v1 { + use sea_orm::entity::prelude::*; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "sync_user")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub email: String, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +// Scenario 3b: updated version — the existing column is made unique. +mod user_v2 { + use sea_orm::entity::prelude::*; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] + #[sea_orm(table_name = "sync_user")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub email: String, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +/// Regression test for . +/// +/// A table with a `#[sea_orm(unique)]` column is created on the first sync. +/// The subsequent sync must not attempt to drop the column-level unique index. +#[sea_orm_macros::test] +async fn test_sync_unique_column_no_drop() -> Result<(), DbErr> { + let ctx = TestContext::new("test_sync_unique_column_no_drop").await; + let db = &ctx.db; + + #[cfg(feature = "schema-sync")] + { + // First sync: creates the table with the unique column + db.get_schema_builder() + .register(item_v1::Entity) + .sync(db) + .await?; + + // Second sync: must not try to drop the column-level unique index + db.get_schema_builder() + .register(item_v1::Entity) + .sync(db) + .await?; + + #[cfg(feature = "sqlx-postgres")] + assert!( + pg_index_exists(db, "sync_item", "sync_item_name_key").await?, + "unique index on `sync_item.name` should still exist after repeated sync" + ); + } + + Ok(()) +} + +/// Regression test for . +/// +/// A unique column is added to an existing table via sync (ALTER TABLE ADD +/// COLUMN … UNIQUE), which creates a column-level unique index. A subsequent +/// sync must not attempt to drop that index. +#[sea_orm_macros::test] +#[cfg(not(any(feature = "sqlx-sqlite", feature = "rusqlite")))] +async fn test_sync_add_unique_column_no_drop() -> Result<(), DbErr> { + let ctx = TestContext::new("test_sync_add_unique_column_no_drop").await; + let db = &ctx.db; + + #[cfg(feature = "schema-sync")] + { + // First sync: creates the table without the unique column + db.get_schema_builder() + .register(product_v1::Entity) + .sync(db) + .await?; + + // Second sync: adds the unique column via ALTER TABLE ADD COLUMN … UNIQUE + db.get_schema_builder() + .register(product_v2::Entity) + .sync(db) + .await?; + + // Third sync: must not try to drop the unique index created above + db.get_schema_builder() + .register(product_v2::Entity) + .sync(db) + .await?; + + #[cfg(feature = "sqlx-postgres")] + assert!( + pg_index_exists(db, "sync_product", "sync_product_sku_key").await?, + "unique index on `sync_product.sku` should still exist after repeated sync" + ); + } + + Ok(()) +} + +/// Scenario 3: an existing column is made unique in a later sync. +/// +/// When a column that already exists in the DB is annotated with +/// `#[sea_orm(unique)]`, the sync must create a unique index for it. +#[sea_orm_macros::test] +async fn test_sync_make_existing_column_unique() -> Result<(), DbErr> { + let ctx = TestContext::new("test_sync_make_existing_column_unique").await; + let db = &ctx.db; + + #[cfg(feature = "schema-sync")] + { + // First sync: creates the table with a plain (non-unique) email column + db.get_schema_builder() + .register(user_v1::Entity) + .sync(db) + .await?; + + // Second sync: email is now marked unique — should create the unique index + db.get_schema_builder() + .register(user_v2::Entity) + .sync(db) + .await?; + + // Third sync: must not try to drop or re-create the index + db.get_schema_builder() + .register(user_v2::Entity) + .sync(db) + .await?; + + #[cfg(feature = "sqlx-postgres")] + assert!( + pg_index_exists(db, "sync_user", "idx-sync_user-email").await?, + "unique index on `sync_user.email` should be created when column is made unique" + ); + } + + Ok(()) +} + +#[cfg(feature = "sqlx-postgres")] +async fn pg_index_exists(db: &DatabaseConnection, table: &str, index: &str) -> Result { + db.query_one( + Query::select() + .expr(Expr::cust("COUNT(*) > 0")) + .from("pg_indexes") + .cond_where( + Condition::all() + .add(Expr::cust("schemaname = CURRENT_SCHEMA()")) + .add(Expr::col("tablename").eq(table)) + .add(Expr::col("indexname").eq(index)), + ), + ) + .await? + .unwrap() + .try_get_by_index(0) + .map_err(DbErr::from) +}