Skip to content

Commit 1f4b5f2

Browse files
feat(sqlite): no_tx migration support (#4015)
* chore(sqlx-postgres): fix typo in `migrate.rs` comment * 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. * test(sqlite): add test for `no_tx` migrations * chore(sqlx-sqlite): bring back useful comment * chore(sqlx-sqlite): unify SQL dialect in annotation comments
1 parent 66526d9 commit 1f4b5f2

File tree

4 files changed

+87
-42
lines changed

4 files changed

+87
-42
lines changed

sqlx-postgres/src/migrate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ CREATE TABLE IF NOT EXISTS {table_name} (
276276
if migration.no_tx {
277277
revert_migration(self, table_name, migration).await?;
278278
} else {
279-
// Use a single transaction for the actual migration script and the essential bookeeping so we never
279+
// Use a single transaction for the actual migration script and the essential bookkeeping so we never
280280
// execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966.
281281
let mut tx = self.begin().await?;
282282
revert_migration(&mut tx, table_name, migration).await?;

sqlx-sqlite/src/migrate.rs

Lines changed: 72 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -160,41 +160,27 @@ CREATE TABLE IF NOT EXISTS {table_name} (
160160
migration: &'e Migration,
161161
) -> BoxFuture<'e, Result<Duration, MigrateError>> {
162162
Box::pin(async move {
163-
let mut tx = self.begin().await?;
164163
let start = Instant::now();
165164

166-
// Use a single transaction for the actual migration script and the essential bookeeping so we never
167-
// execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966.
168-
// The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for
169-
// data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1
170-
// and update it once the actual transaction completed.
171-
let _ = tx
172-
.execute(migration.sql.clone())
173-
.await
174-
.map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?;
175-
176-
// language=SQL
177-
let _ = query(AssertSqlSafe(format!(
178-
r#"
179-
INSERT INTO {table_name} ( version, description, success, checksum, execution_time )
180-
VALUES ( ?1, ?2, TRUE, ?3, -1 )
181-
"#
182-
)))
183-
.bind(migration.version)
184-
.bind(&*migration.description)
185-
.bind(&*migration.checksum)
186-
.execute(&mut *tx)
187-
.await?;
188-
189-
tx.commit().await?;
165+
if migration.no_tx {
166+
execute_migration(self, table_name, migration).await?;
167+
} else {
168+
// Use a single transaction for the actual migration script and the essential bookkeeping so we never
169+
// execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966.
170+
// The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for
171+
// data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1
172+
// and update it once the actual transaction completed.
173+
let mut tx = self.begin().await?;
174+
execute_migration(&mut tx, table_name, migration).await?;
175+
tx.commit().await?;
176+
}
190177

191178
// Update `elapsed_time`.
192179
// NOTE: The process may disconnect/die at this point, so the elapsed time value might be lost. We accept
193180
// this small risk since this value is not super important.
194-
195181
let elapsed = start.elapsed();
196182

197-
// language=SQL
183+
// language=SQLite
198184
#[allow(clippy::cast_possible_truncation)]
199185
let _ = query(AssertSqlSafe(format!(
200186
r#"
@@ -218,26 +204,71 @@ CREATE TABLE IF NOT EXISTS {table_name} (
218204
migration: &'e Migration,
219205
) -> BoxFuture<'e, Result<Duration, MigrateError>> {
220206
Box::pin(async move {
221-
// Use a single transaction for the actual migration script and the essential bookeeping so we never
222-
// execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966.
223-
let mut tx = self.begin().await?;
224207
let start = Instant::now();
225208

226-
let _ = tx.execute(migration.sql.clone()).await?;
227-
228-
// language=SQLite
229-
let _ = query(AssertSqlSafe(format!(
230-
r#"DELETE FROM {table_name} WHERE version = ?1"#
231-
)))
232-
.bind(migration.version)
233-
.execute(&mut *tx)
234-
.await?;
235-
236-
tx.commit().await?;
209+
if migration.no_tx {
210+
execute_migration(self, table_name, migration).await?;
211+
} else {
212+
// Use a single transaction for the actual migration script and the essential bookkeeping so we never
213+
// execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966.
214+
let mut tx = self.begin().await?;
215+
revert_migration(&mut tx, table_name, migration).await?;
216+
tx.commit().await?;
217+
}
237218

238219
let elapsed = start.elapsed();
239220

240221
Ok(elapsed)
241222
})
242223
}
243224
}
225+
226+
async fn execute_migration(
227+
conn: &mut SqliteConnection,
228+
table_name: &str,
229+
migration: &Migration,
230+
) -> Result<(), MigrateError> {
231+
let _ = conn
232+
.execute(migration.sql.clone())
233+
.await
234+
.map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?;
235+
236+
// language=SQLite
237+
let _ = query(AssertSqlSafe(format!(
238+
r#"
239+
INSERT INTO {table_name} ( version, description, success, checksum, execution_time )
240+
VALUES ( ?1, ?2, TRUE, ?3, -1 )
241+
"#
242+
)))
243+
.bind(migration.version)
244+
.bind(&*migration.description)
245+
.bind(&*migration.checksum)
246+
.execute(conn)
247+
.await?;
248+
249+
Ok(())
250+
}
251+
252+
async fn revert_migration(
253+
conn: &mut SqliteConnection,
254+
table_name: &str,
255+
migration: &Migration,
256+
) -> Result<(), MigrateError> {
257+
let _ = conn
258+
.execute(migration.sql.clone())
259+
.await
260+
.map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?;
261+
262+
// language=SQLite
263+
let _ = query(AssertSqlSafe(format!(
264+
r#"
265+
DELETE FROM {table_name}
266+
WHERE version = ?1
267+
"#
268+
)))
269+
.bind(migration.version)
270+
.execute(conn)
271+
.await?;
272+
273+
Ok(())
274+
}

tests/sqlite/migrate.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ async fn reversible(mut conn: PoolConnection<Sqlite>) -> anyhow::Result<()> {
6666
Ok(())
6767
}
6868

69+
#[sqlx::test(migrations = false)]
70+
async fn no_tx(mut conn: PoolConnection<Sqlite>) -> anyhow::Result<()> {
71+
clean_up(&mut conn).await?;
72+
let migrator = Migrator::new(Path::new("tests/sqlite/migrations_no_tx")).await?;
73+
74+
// run migration
75+
migrator.run(&mut conn).await?;
76+
77+
Ok(())
78+
}
79+
6980
/// Ensure that we have a clean initial state.
7081
async fn clean_up(conn: &mut SqliteConnection) -> anyhow::Result<()> {
7182
conn.execute("DROP TABLE migrations_simple_test").await.ok();
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- no-transaction
2+
3+
VACUUM;

0 commit comments

Comments
 (0)