Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions sea-orm-arrow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
61 changes: 59 additions & 2 deletions src/schema/builder.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
226 changes: 226 additions & 0 deletions tests/schema_sync_tests.rs
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/SeaQL/sea-orm/issues/2970>.
///
/// 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 <https://github.com/SeaQL/sea-orm/issues/2970>.
///
/// 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<bool, DbErr> {
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)
}
Loading