From 8fff06be689c566ba5c49f6549ec12e9b1402803 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Tue, 10 Nov 2026 19:48:36 +0600 Subject: [PATCH 1/2] fix(core): accept transaction callbacks without Sync Require only Send for the owned FnOnce callback. The returned future remains Send, while captures such as mpsc::Receiver are now accepted. --- sqlx-core/src/connection.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sqlx-core/src/connection.rs b/sqlx-core/src/connection.rs index b5f8138b2b..e6616bb6e8 100644 --- a/sqlx-core/src/connection.rs +++ b/sqlx-core/src/connection.rs @@ -103,8 +103,7 @@ pub trait Connection: Send { where for<'c> F: FnOnce(&'c mut Transaction<'_, Self::Database>) -> BoxFuture<'c, Result> + 'a - + Send - + Sync, + + Send, Self: Sized, R: Send, E: From + Send, From eea4454e2b37ba51026b6b83b2fed32cdb98cfb0 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Tue, 10 Nov 2026 19:48:36 +0600 Subject: [PATCH 2/2] test(core): cover transaction callbacks across database drivers Share regression coverage between PostgreSQL, MySQL/MariaDB, SQLite, and Any connections. Check non-Sync captures, future sendability, commit, and rollback. --- sqlx-test/src/lib.rs | 55 +++++++++++++++++++++++++++++++++++++- tests/any/any.rs | 8 ++++++ tests/mysql/mysql.rs | 7 +++++ tests/postgres/postgres.rs | 7 +++++ tests/sqlite/sqlite.rs | 7 +++++ 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/sqlx-test/src/lib.rs b/sqlx-test/src/lib.rs index 3744724c12..5cff6b8a35 100644 --- a/sqlx-test/src/lib.rs +++ b/sqlx-test/src/lib.rs @@ -1,5 +1,5 @@ use sqlx::pool::PoolOptions; -use sqlx::{Connection, Database, Error, Pool}; +use sqlx::{Connection, Database, Error, Executor, Pool}; use std::env; pub fn setup_if_needed() { @@ -38,6 +38,59 @@ where Ok(pool) } +/// Check that Send-only transaction callbacks can commit and roll back. +pub async fn test_send_only_transaction_callbacks(conn: &mut C) -> anyhow::Result<()> +where + C: Connection, + for<'c> &'c mut C: Executor<'c, Database = C::Database>, +{ + fn assert_send(_: &(impl std::future::Future + Send)) {} + + sqlx::raw_sql("CREATE TEMPORARY TABLE transaction_test (value BIGINT)") + .execute(&mut *conn) + .await?; + + for commit in [true, false] { + // A Receiver is Send but not Sync. + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(42_i64)?; + + let future = conn.transaction(move |tx| { + Box::pin(async move { + sqlx::raw_sql("INSERT INTO transaction_test (value) VALUES (42)") + .execute(&mut **tx) + .await?; + + // Keep the receiver across an await in the callback future, too. + let value = receiver.try_recv().unwrap(); + + if commit { + Ok(value) + } else { + Err(sqlx::Error::RowNotFound) + } + }) + }); + + assert_send(&future); + let result = future.await; + + if commit { + assert_eq!(result?, 42); + } else { + assert!(matches!(result, Err(sqlx::Error::RowNotFound))); + } + + assert!(!conn.is_in_transaction()); + let rows = sqlx::raw_sql("SELECT value FROM transaction_test") + .fetch_all(&mut *conn) + .await?; + assert_eq!(rows.len(), 1); + } + + Ok(()) +} + // Test type encoding and decoding #[macro_export] macro_rules! test_type { diff --git a/tests/any/any.rs b/tests/any/any.rs index 2c57a237ec..e33ad229ab 100644 --- a/tests/any/any.rs +++ b/tests/any/any.rs @@ -23,6 +23,14 @@ async fn it_connects() -> anyhow::Result<()> { Ok(()) } +#[sqlx_macros::test] +async fn it_accepts_send_only_transaction_callbacks() -> anyhow::Result<()> { + sqlx::any::install_default_drivers(); + let mut conn = new::().await?; + + sqlx_test::test_send_only_transaction_callbacks(&mut conn).await +} + #[sqlx_macros::test] async fn it_pings() -> anyhow::Result<()> { sqlx::any::install_default_drivers(); diff --git a/tests/mysql/mysql.rs b/tests/mysql/mysql.rs index 1e6aa85fd5..46d7891b91 100644 --- a/tests/mysql/mysql.rs +++ b/tests/mysql/mysql.rs @@ -19,6 +19,13 @@ async fn it_connects() -> anyhow::Result<()> { Ok(()) } +#[sqlx_macros::test] +async fn it_accepts_send_only_transaction_callbacks() -> anyhow::Result<()> { + let mut conn = new::().await?; + + sqlx_test::test_send_only_transaction_callbacks(&mut conn).await +} + #[sqlx_macros::test] async fn it_connects_without_password() -> anyhow::Result<()> { setup_if_needed(); diff --git a/tests/postgres/postgres.rs b/tests/postgres/postgres.rs index ac0fe7478b..9c86f23558 100644 --- a/tests/postgres/postgres.rs +++ b/tests/postgres/postgres.rs @@ -28,6 +28,13 @@ async fn it_connects() -> anyhow::Result<()> { Ok(()) } +#[sqlx_macros::test] +async fn it_accepts_send_only_transaction_callbacks() -> anyhow::Result<()> { + let mut conn = new::().await?; + + sqlx_test::test_send_only_transaction_callbacks(&mut conn).await +} + #[sqlx_macros::test] async fn it_can_select_void() -> anyhow::Result<()> { let mut conn = new::().await?; diff --git a/tests/sqlite/sqlite.rs b/tests/sqlite/sqlite.rs index 8791443b4b..a5be0d6fb7 100644 --- a/tests/sqlite/sqlite.rs +++ b/tests/sqlite/sqlite.rs @@ -1344,6 +1344,13 @@ async fn test_serialize_invalid_schema() -> anyhow::Result<()> { Ok(()) } +#[sqlx_macros::test] +async fn it_accepts_send_only_transaction_callbacks() -> anyhow::Result<()> { + let mut conn = SqliteConnection::connect(":memory:").await?; + + sqlx_test::test_send_only_transaction_callbacks(&mut conn).await +} + #[sqlx_macros::test] async fn it_can_use_transaction_options() -> anyhow::Result<()> { async fn check_txn_state(conn: &mut SqliteConnection, expected: SqliteTransactionState) {