From 5053a99a01bd9b50fadd0991ca6771dbeeebcdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 4 Sep 2025 11:10:07 +0200 Subject: [PATCH 1/5] chore(sqlx-postgres): fix typo in `migrate.rs` comment --- sqlx-postgres/src/migrate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlx-postgres/src/migrate.rs b/sqlx-postgres/src/migrate.rs index b96c021be2..49104672c7 100644 --- a/sqlx-postgres/src/migrate.rs +++ b/sqlx-postgres/src/migrate.rs @@ -276,7 +276,7 @@ CREATE TABLE IF NOT EXISTS {table_name} ( if migration.no_tx { revert_migration(self, table_name, migration).await?; } else { - // Use a single transaction for the actual migration script and the essential bookeeping so we never + // Use a single transaction for the actual migration script and the essential bookkeeping so we never // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. let mut tx = self.begin().await?; revert_migration(&mut tx, table_name, migration).await?; From ceab80450b28584b586ea344d14663718f0f0c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 4 Sep 2025 11:14:19 +0200 Subject: [PATCH 2/5] feat(sqlite): support `no_tx` migrations SQLite includes several SQL statements that are useful during migrations but must be executed outside of a transaction to take effect, such as `PRAGMA foreign_keys = ON|OFF` or `VACUUM`. Additionally, advanced migrations may want more precise control over how statements are grouped into transactions or savepoints to achieve the desired atomicity for different parts of the migration. While SQLx already supports marking migrations to run outside explicit transactions through a `-- no-transaction` comment, this feature is currently only available for `PgConnection`'s `Migrate` implementation, leaving SQLite and MySQL without this capability. Although it's possible to work around this limitation by implementing custom migration logic instead of executing `Migrator#run`, this comes at a cost of significantly reduced developer ergonomics: code that relies on the default migration logic, such as `#[sqlx::test]` or `cargo sqlx database setup`, won't support these migrations. These changes extend `SqliteConnection`'s `Migrate` implementation to support `no_tx` migrations in the same way as PostgreSQL, addressing this feature gap. I also considered implementing the same functionality for MySQL, but since I haven't found a practical use case for it yet, and every non-transaction-friendly statement I could think about in MySQL triggers implicit commits anyway, I determined it wasn't necessary at this time and could be considered an overreach. --- sqlx-sqlite/src/migrate.rs | 109 +++++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 40 deletions(-) diff --git a/sqlx-sqlite/src/migrate.rs b/sqlx-sqlite/src/migrate.rs index 6f1796d376..857bb16e8c 100644 --- a/sqlx-sqlite/src/migrate.rs +++ b/sqlx-sqlite/src/migrate.rs @@ -160,38 +160,24 @@ CREATE TABLE IF NOT EXISTS {table_name} ( migration: &'e Migration, ) -> BoxFuture<'e, Result> { Box::pin(async move { - let mut tx = self.begin().await?; let start = Instant::now(); - // Use a single transaction for the actual migration script and the essential bookeeping so we never - // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. - // The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for - // data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1 - // and update it once the actual transaction completed. - let _ = tx - .execute(migration.sql.clone()) - .await - .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; - - // language=SQL - let _ = query(AssertSqlSafe(format!( - r#" - INSERT INTO {table_name} ( version, description, success, checksum, execution_time ) - VALUES ( ?1, ?2, TRUE, ?3, -1 ) - "# - ))) - .bind(migration.version) - .bind(&*migration.description) - .bind(&*migration.checksum) - .execute(&mut *tx) - .await?; - - tx.commit().await?; + if migration.no_tx { + execute_migration(self, table_name, migration).await?; + } else { + // Use a single transaction for the actual migration script and the essential bookkeeping so we never + // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. + // The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for + // data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1 + // and update it once the actual transaction completed. + let mut tx = self.begin().await?; + execute_migration(&mut tx, table_name, migration).await?; + tx.commit().await?; + } // Update `elapsed_time`. // NOTE: The process may disconnect/die at this point, so the elapsed time value might be lost. We accept // this small risk since this value is not super important. - let elapsed = start.elapsed(); // language=SQL @@ -218,22 +204,15 @@ CREATE TABLE IF NOT EXISTS {table_name} ( migration: &'e Migration, ) -> BoxFuture<'e, Result> { Box::pin(async move { - // Use a single transaction for the actual migration script and the essential bookeeping so we never - // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. - let mut tx = self.begin().await?; let start = Instant::now(); - let _ = tx.execute(migration.sql.clone()).await?; - - // language=SQLite - let _ = query(AssertSqlSafe(format!( - r#"DELETE FROM {table_name} WHERE version = ?1"# - ))) - .bind(migration.version) - .execute(&mut *tx) - .await?; - - tx.commit().await?; + if migration.no_tx { + execute_migration(self, table_name, migration).await?; + } else { + let mut tx = self.begin().await?; + revert_migration(&mut tx, table_name, migration).await?; + tx.commit().await?; + } let elapsed = start.elapsed(); @@ -241,3 +220,53 @@ CREATE TABLE IF NOT EXISTS {table_name} ( }) } } + +async fn execute_migration( + conn: &mut SqliteConnection, + table_name: &str, + migration: &Migration, +) -> Result<(), MigrateError> { + let _ = conn + .execute(migration.sql.clone()) + .await + .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; + + // language=SQL + let _ = query(AssertSqlSafe(format!( + r#" + INSERT INTO {table_name} ( version, description, success, checksum, execution_time ) + VALUES ( ?1, ?2, TRUE, ?3, -1 ) + "# + ))) + .bind(migration.version) + .bind(&*migration.description) + .bind(&*migration.checksum) + .execute(conn) + .await?; + + Ok(()) +} + +async fn revert_migration( + conn: &mut SqliteConnection, + table_name: &str, + migration: &Migration, +) -> Result<(), MigrateError> { + let _ = conn + .execute(migration.sql.clone()) + .await + .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; + + // language=SQL + let _ = query(AssertSqlSafe(format!( + r#" + DELETE FROM {table_name} + WHERE version = ?1 + "# + ))) + .bind(migration.version) + .execute(conn) + .await?; + + Ok(()) +} From 34677793eda043353ebab313593d44ea81f2c1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 4 Sep 2025 13:09:07 +0200 Subject: [PATCH 3/5] test(sqlite): add test for `no_tx` migrations --- tests/sqlite/migrate.rs | 11 +++++++++++ tests/sqlite/migrations_no_tx/0_vacuum.sql | 3 +++ 2 files changed, 14 insertions(+) create mode 100644 tests/sqlite/migrations_no_tx/0_vacuum.sql diff --git a/tests/sqlite/migrate.rs b/tests/sqlite/migrate.rs index 19e8690f9a..a2315af284 100644 --- a/tests/sqlite/migrate.rs +++ b/tests/sqlite/migrate.rs @@ -66,6 +66,17 @@ async fn reversible(mut conn: PoolConnection) -> anyhow::Result<()> { Ok(()) } +#[sqlx::test(migrations = false)] +async fn no_tx(mut conn: PoolConnection) -> anyhow::Result<()> { + clean_up(&mut conn).await?; + let migrator = Migrator::new(Path::new("tests/sqlite/migrations_no_tx")).await?; + + // run migration + migrator.run(&mut conn).await?; + + Ok(()) +} + /// Ensure that we have a clean initial state. async fn clean_up(conn: &mut SqliteConnection) -> anyhow::Result<()> { conn.execute("DROP TABLE migrations_simple_test").await.ok(); diff --git a/tests/sqlite/migrations_no_tx/0_vacuum.sql b/tests/sqlite/migrations_no_tx/0_vacuum.sql new file mode 100644 index 0000000000..cd42df41f2 --- /dev/null +++ b/tests/sqlite/migrations_no_tx/0_vacuum.sql @@ -0,0 +1,3 @@ +-- no-transaction + +VACUUM; From c7deb6ea950c13ade47ff0bfa1dd42de0e0397f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 4 Sep 2025 13:22:54 +0200 Subject: [PATCH 4/5] chore(sqlx-sqlite): bring back useful comment --- sqlx-sqlite/src/migrate.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sqlx-sqlite/src/migrate.rs b/sqlx-sqlite/src/migrate.rs index 857bb16e8c..5e18b93817 100644 --- a/sqlx-sqlite/src/migrate.rs +++ b/sqlx-sqlite/src/migrate.rs @@ -209,6 +209,8 @@ CREATE TABLE IF NOT EXISTS {table_name} ( if migration.no_tx { execute_migration(self, table_name, migration).await?; } else { + // Use a single transaction for the actual migration script and the essential bookkeeping so we never + // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. let mut tx = self.begin().await?; revert_migration(&mut tx, table_name, migration).await?; tx.commit().await?; From a69fd631b6b06aeb3b38105df65759a66d1cbc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= Date: Thu, 4 Sep 2025 13:24:21 +0200 Subject: [PATCH 5/5] chore(sqlx-sqlite): unify SQL dialect in annotation comments --- sqlx-sqlite/src/migrate.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sqlx-sqlite/src/migrate.rs b/sqlx-sqlite/src/migrate.rs index 5e18b93817..7eb8cc1ca9 100644 --- a/sqlx-sqlite/src/migrate.rs +++ b/sqlx-sqlite/src/migrate.rs @@ -180,7 +180,7 @@ CREATE TABLE IF NOT EXISTS {table_name} ( // this small risk since this value is not super important. let elapsed = start.elapsed(); - // language=SQL + // language=SQLite #[allow(clippy::cast_possible_truncation)] let _ = query(AssertSqlSafe(format!( r#" @@ -233,7 +233,7 @@ async fn execute_migration( .await .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; - // language=SQL + // language=SQLite let _ = query(AssertSqlSafe(format!( r#" INSERT INTO {table_name} ( version, description, success, checksum, execution_time ) @@ -259,7 +259,7 @@ async fn revert_migration( .await .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; - // language=SQL + // language=SQLite let _ = query(AssertSqlSafe(format!( r#" DELETE FROM {table_name}