Skip to content

Commit cbf1ec3

Browse files
authored
Add per-migration transaction control (#2980)
* Add per-migration transaction control for SeaORM migrations + Add MigrationTrait::use_transaction() (None/Some(true)/Some(false)) and SchemaManager::begin()/commit() for manual control + Change up()/down() from single batch transaction to per-migration + Add DatabaseExecutor::OwnedTransaction variant and is_transaction() method * Add one more test case
1 parent e5a5f39 commit cbf1ec3

15 files changed

Lines changed: 426 additions & 65 deletions

File tree

sea-orm-migration/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ tracing-subscriber = { version = "0.3.17", default-features = false, features =
3939
] }
4040

4141
[dev-dependencies]
42-
async-std = { version = "1", features = ["attributes", "tokio1"] }
42+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
4343

4444
[features]
4545
cli = ["clap", "dotenvy", "sea-orm-cli/cli"]

sea-orm-migration/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,13 @@ pub trait MigrationTrait: MigrationName + Send + Sync {
3131
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
3232
Err(DbErr::Migration("We Don't Do That Here".to_owned()))
3333
}
34+
35+
/// Control whether this migration runs inside a transaction.
36+
///
37+
/// - `None` (default): follow backend convention (Postgres = transaction, MySQL/SQLite = no transaction)
38+
/// - `Some(true)`: force wrapping in a transaction on any backend
39+
/// - `Some(false)`: disable automatic transaction wrapping (use `manager.begin()` for manual control)
40+
fn use_transaction(&self) -> Option<bool> {
41+
None
42+
}
3443
}

sea-orm-migration/src/manager.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use sea_orm::sea_query::{
55
TableRenameStatement, TableTruncateStatement,
66
extension::postgres::{TypeAlterStatement, TypeCreateStatement, TypeDropStatement},
77
};
8-
use sea_orm::{ConnectionTrait, DbBackend, DbErr, StatementBuilder};
8+
use sea_orm::{ConnectionTrait, DbBackend, DbErr, StatementBuilder, TransactionTrait};
99
#[allow(unused_imports)]
1010
use sea_schema::probe::SchemaProbe;
1111

@@ -48,6 +48,30 @@ impl<'c> SchemaManager<'c> {
4848
}
4949
}
5050

51+
/// Transaction Control
52+
impl SchemaManager<'_> {
53+
/// Begin a new transaction, returning an owned `SchemaManager` backed by it.
54+
///
55+
/// Useful in migrations with `use_transaction() -> Some(false)` for manual
56+
/// transaction management (e.g., separating DDL and DML into distinct transactions).
57+
pub async fn begin(&self) -> Result<SchemaManager<'static>, DbErr> {
58+
let txn = self.conn.begin().await?;
59+
Ok(SchemaManager {
60+
conn: SchemaManagerConnection::OwnedTransaction(txn),
61+
})
62+
}
63+
64+
/// Commit the owned transaction. Only valid on a `SchemaManager` created by [`begin()`](Self::begin).
65+
pub async fn commit(self) -> Result<(), DbErr> {
66+
match self.conn {
67+
SchemaManagerConnection::OwnedTransaction(txn) => txn.commit().await,
68+
_ => Err(DbErr::Custom(
69+
"Cannot commit: SchemaManager does not own a transaction".into(),
70+
)),
71+
}
72+
}
73+
}
74+
5175
/// Schema Creation
5276
impl SchemaManager<'_> {
5377
pub async fn create_table(&self, stmt: TableCreateStatement) -> Result<(), DbErr> {

sea-orm-migration/src/migrator.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,21 +194,19 @@ pub trait MigratorTrait: Send {
194194
where
195195
C: IntoSchemaManagerConnection<'c>,
196196
{
197-
exec_with_connection!(db, async |manager| {
198-
exec_up::<Self>(manager, steps).await
199-
})
200-
.await
197+
let db = db.into_database_executor();
198+
let manager = SchemaManager::new(db);
199+
exec_up::<Self>(&manager, steps).await
201200
}
202201

203202
/// Rollback applied migrations
204203
async fn down<'c, C>(db: C, steps: Option<u32>) -> Result<(), DbErr>
205204
where
206205
C: IntoSchemaManagerConnection<'c>,
207206
{
208-
exec_with_connection!(db, async |manager| {
209-
exec_down::<Self>(manager, steps).await
210-
})
211-
.await
207+
let db = db.into_database_executor();
208+
let manager = SchemaManager::new(db);
209+
exec_down::<Self>(&manager, steps).await
212210
}
213211
}
214212

sea-orm-migration/src/migrator/exec.rs

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use sea_orm::sea_query::{
1010
};
1111
use sea_orm::{
1212
ActiveValue, ConnectionTrait, DbBackend, DbErr, DynIden, EntityTrait, FromQueryResult,
13-
Iterable, QueryFilter, Schema, Statement,
13+
Iterable, QueryFilter, Schema, Statement, TransactionTrait,
1414
};
1515

1616
pub async fn get_migration_models<C>(
@@ -198,6 +198,48 @@ pub async fn drop_everything<C: ConnectionTrait>(db: &C) -> Result<(), DbErr> {
198198
Ok(())
199199
}
200200

201+
fn should_use_transaction(migration: &dyn crate::MigrationTrait, backend: DbBackend) -> bool {
202+
match migration.use_transaction() {
203+
Some(v) => v,
204+
None => backend == DbBackend::Postgres,
205+
}
206+
}
207+
208+
async fn insert_migration_record<C: ConnectionTrait>(
209+
db: &C,
210+
name: &str,
211+
migration_table_name: DynIden,
212+
) -> Result<(), DbErr> {
213+
#[cfg(not(feature = "with-time"))]
214+
let applied_at = SystemTime::now()
215+
.duration_since(SystemTime::UNIX_EPOCH)
216+
.expect("SystemTime before UNIX EPOCH!")
217+
.as_secs() as i64;
218+
#[cfg(feature = "with-time")]
219+
let applied_at = sea_orm::prelude::TimeDateTimeWithTimeZone::now_utc().unix_timestamp();
220+
seaql_migrations::Entity::insert(seaql_migrations::ActiveModel {
221+
version: ActiveValue::Set(name.to_owned()),
222+
applied_at: ActiveValue::Set(applied_at),
223+
})
224+
.table_name(migration_table_name)
225+
.exec(db)
226+
.await?;
227+
Ok(())
228+
}
229+
230+
async fn delete_migration_record<C: ConnectionTrait>(
231+
db: &C,
232+
name: &str,
233+
migration_table_name: DynIden,
234+
) -> Result<(), DbErr> {
235+
seaql_migrations::Entity::delete_many()
236+
.filter(Expr::col(seaql_migrations::Column::Version).eq(name))
237+
.table_name(migration_table_name)
238+
.exec(db)
239+
.await?;
240+
Ok(())
241+
}
242+
201243
pub async fn exec_up_with(
202244
manager: &SchemaManager<'_>,
203245
mut steps: Option<u32>,
@@ -222,23 +264,23 @@ pub async fn exec_up_with(
222264
}
223265
*steps -= 1;
224266
}
267+
268+
let use_txn = should_use_transaction(migration.as_ref(), db.get_database_backend());
225269
info!("Applying migration '{}'", migration.name());
226-
migration.up(manager).await?;
227-
info!("Migration '{}' has been applied", migration.name());
228-
#[cfg(not(feature = "with-time"))]
229-
let applied_at = SystemTime::now()
230-
.duration_since(SystemTime::UNIX_EPOCH)
231-
.expect("SystemTime before UNIX EPOCH!")
232-
.as_secs() as i64;
233-
#[cfg(feature = "with-time")]
234-
let applied_at = sea_orm::prelude::TimeDateTimeWithTimeZone::now_utc().unix_timestamp();
235-
seaql_migrations::Entity::insert(seaql_migrations::ActiveModel {
236-
version: ActiveValue::Set(migration.name().to_owned()),
237-
applied_at: ActiveValue::Set(applied_at),
238-
})
239-
.table_name(migration_table_name.clone())
240-
.exec(db)
241-
.await?;
270+
271+
if use_txn {
272+
let transaction = db.begin().await?;
273+
let txn_manager = SchemaManager::new(&transaction);
274+
migration.up(&txn_manager).await?;
275+
info!("Migration '{}' has been applied", migration.name());
276+
insert_migration_record(&transaction, migration.name(), migration_table_name.clone())
277+
.await?;
278+
transaction.commit().await?;
279+
} else {
280+
migration.up(manager).await?;
281+
info!("Migration '{}' has been applied", migration.name());
282+
insert_migration_record(db, migration.name(), migration_table_name.clone()).await?;
283+
}
242284
}
243285

244286
Ok(())
@@ -268,14 +310,23 @@ pub async fn exec_down_with(
268310
}
269311
*steps -= 1;
270312
}
313+
314+
let use_txn = should_use_transaction(migration.as_ref(), db.get_database_backend());
271315
info!("Rolling back migration '{}'", migration.name());
272-
migration.down(manager).await?;
273-
info!("Migration '{}' has been rollbacked", migration.name());
274-
seaql_migrations::Entity::delete_many()
275-
.filter(Expr::col(seaql_migrations::Column::Version).eq(migration.name()))
276-
.table_name(migration_table_name.clone())
277-
.exec(db)
278-
.await?;
316+
317+
if use_txn {
318+
let transaction = db.begin().await?;
319+
let txn_manager = SchemaManager::new(&transaction);
320+
migration.down(&txn_manager).await?;
321+
info!("Migration '{}' has been rolled back", migration.name());
322+
delete_migration_record(&transaction, migration.name(), migration_table_name.clone())
323+
.await?;
324+
transaction.commit().await?;
325+
} else {
326+
migration.down(manager).await?;
327+
info!("Migration '{}' has been rolled back", migration.name());
328+
delete_migration_record(db, migration.name(), migration_table_name.clone()).await?;
329+
}
279330
}
280331

281332
Ok(())

sea-orm-migration/src/migrator/with_self.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,18 +152,19 @@ pub trait MigratorTraitSelf: Sized + Send + Sync {
152152
where
153153
C: IntoSchemaManagerConnection<'c>,
154154
{
155-
exec_with_connection!(db, async |manager| { exec_up(self, manager, steps).await }).await
155+
let db = db.into_database_executor();
156+
let manager = SchemaManager::new(db);
157+
exec_up(self, &manager, steps).await
156158
}
157159

158160
/// Rollback applied migrations
159161
async fn down<'c, C>(&self, db: C, steps: Option<u32>) -> Result<(), DbErr>
160162
where
161163
C: IntoSchemaManagerConnection<'c>,
162164
{
163-
exec_with_connection!(db, async |manager| {
164-
exec_down(self, manager, steps).await
165-
})
166-
.await
165+
let db = db.into_database_executor();
166+
let manager = SchemaManager::new(db);
167+
exec_down(self, &manager, steps).await
167168
}
168169
}
169170

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use sea_orm_migration::prelude::*;
2+
use sea_orm_migration::schema::*;
3+
use sea_orm_migration::sea_orm::DbBackend;
4+
5+
pub struct Migration {
6+
pub use_transaction: Option<bool>,
7+
pub should_fail: bool,
8+
}
9+
10+
impl MigrationName for Migration {
11+
fn name(&self) -> &str {
12+
"m20250101_000001_create_test_table"
13+
}
14+
}
15+
16+
#[async_trait::async_trait]
17+
impl MigrationTrait for Migration {
18+
fn use_transaction(&self) -> Option<bool> {
19+
self.use_transaction
20+
}
21+
22+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
23+
let expect_txn = self
24+
.use_transaction
25+
.unwrap_or(manager.get_database_backend() == DbBackend::Postgres);
26+
assert_eq!(
27+
manager.get_connection().is_transaction(),
28+
expect_txn,
29+
"up: expected is_transaction() = {expect_txn}"
30+
);
31+
32+
manager
33+
.create_table(
34+
Table::create()
35+
.table("test_table")
36+
.col(pk_auto("id"))
37+
.col(string("name"))
38+
.to_owned(),
39+
)
40+
.await?;
41+
42+
if self.should_fail {
43+
return Err(DbErr::Migration("intentional failure".into()));
44+
}
45+
46+
Ok(())
47+
}
48+
49+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
50+
let expect_txn = self
51+
.use_transaction
52+
.unwrap_or(manager.get_database_backend() == DbBackend::Postgres);
53+
assert_eq!(
54+
manager.get_connection().is_transaction(),
55+
expect_txn,
56+
"down: expected is_transaction() = {expect_txn}"
57+
);
58+
59+
manager
60+
.drop_table(Table::drop().table("test_table").to_owned())
61+
.await
62+
}
63+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use sea_orm_migration::prelude::*;
2+
use sea_orm_migration::schema::*;
3+
4+
pub struct Migration;
5+
6+
impl MigrationName for Migration {
7+
fn name(&self) -> &str {
8+
"m20250101_000002_manual_transaction"
9+
}
10+
}
11+
12+
#[async_trait::async_trait]
13+
impl MigrationTrait for Migration {
14+
fn use_transaction(&self) -> Option<bool> {
15+
Some(false)
16+
}
17+
18+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
19+
assert!(
20+
!manager.get_connection().is_transaction(),
21+
"outer manager should not be in a transaction"
22+
);
23+
24+
let m = manager.begin().await?;
25+
assert!(
26+
m.get_connection().is_transaction(),
27+
"inner manager should be in a transaction"
28+
);
29+
m.create_table(
30+
Table::create()
31+
.table("manual_txn_table")
32+
.col(pk_auto("id"))
33+
.col(string("name"))
34+
.to_owned(),
35+
)
36+
.await?;
37+
m.commit().await?;
38+
39+
Ok(())
40+
}
41+
42+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
43+
let m = manager.begin().await?;
44+
m.drop_table(Table::drop().table("manual_txn_table").to_owned())
45+
.await?;
46+
m.commit().await?;
47+
48+
Ok(())
49+
}
50+
}

sea-orm-migration/tests/common/migration/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ pub mod m20220118_000003_seed_cake_table;
44
pub mod m20220118_000004_create_tea_enum;
55
pub mod m20220923_000001_seed_cake_table;
66
pub mod m20230109_000001_seed_cake_table;
7+
pub mod m20250101_000001_create_test_table;
8+
pub mod m20250101_000002_manual_transaction;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pub mod default;
22
pub mod override_migration_table_name;
3+
pub mod transaction_test;
34
pub mod with_self;

0 commit comments

Comments
 (0)