From 7560858f524511e1717038d94fbdd0d6825eb4db Mon Sep 17 00:00:00 2001
From: Victor Nordam Suadicani <v.n.suadicani@gmail.com>
Date: Wed, 12 Mar 2025 15:57:11 +0100
Subject: [PATCH 1/2] query_tuple proof of concept

---
 sqlx-macros-core/src/query/input.rs  | 13 +++++-
 sqlx-macros-core/src/query/mod.rs    |  7 ++-
 sqlx-macros-core/src/query/output.rs | 65 ++++++++++++++++++++++++++++
 src/macros/mod.rs                    | 16 ++++++-
 4 files changed, 97 insertions(+), 4 deletions(-)

diff --git a/sqlx-macros-core/src/query/input.rs b/sqlx-macros-core/src/query/input.rs
index 63e35ec77d..97aa3e7dbd 100644
--- a/sqlx-macros-core/src/query/input.rs
+++ b/sqlx-macros-core/src/query/input.rs
@@ -28,6 +28,7 @@ enum QuerySrc {
 
 pub enum RecordType {
     Given(Type),
+    Tuple,
     Scalar,
     Generated,
 }
@@ -65,13 +66,13 @@ impl Parse for QueryMacroInput {
                 args = Some(exprs.elems.into_iter().collect())
             } else if key == "record" {
                 if !matches!(record_type, RecordType::Generated) {
-                    return Err(input.error("colliding `scalar` or `record` key"));
+                    return Err(input.error("colliding `scalar`, `tuple` or `record` key"));
                 }
 
                 record_type = RecordType::Given(input.parse()?);
             } else if key == "scalar" {
                 if !matches!(record_type, RecordType::Generated) {
-                    return Err(input.error("colliding `scalar` or `record` key"));
+                    return Err(input.error("colliding `scalar`, `tuple` or `record` key"));
                 }
 
                 // we currently expect only `scalar = _`
@@ -79,6 +80,14 @@ impl Parse for QueryMacroInput {
                 // of the column in SQL
                 input.parse::<syn::Token![_]>()?;
                 record_type = RecordType::Scalar;
+            } else if key == "tuple" {
+                if !matches!(record_type, RecordType::Generated) {
+                    return Err(input.error("colliding `scalar`, `tuple` or `record` key"));
+                }
+
+                // we currently expect only `tuple = _`
+                input.parse::<syn::Token![_]>()?;
+                record_type = RecordType::Tuple;
             } else if key == "checked" {
                 let lit_bool = input.parse::<LitBool>()?;
                 checked = lit_bool.value;
diff --git a/sqlx-macros-core/src/query/mod.rs b/sqlx-macros-core/src/query/mod.rs
index 09acff9bd2..5b2084fb5c 100644
--- a/sqlx-macros-core/src/query/mod.rs
+++ b/sqlx-macros-core/src/query/mod.rs
@@ -200,7 +200,7 @@ pub fn expand_input<'a>(
             database_url_parsed,
             ..
         } => Err(format!(
-            "no database driver found matching URL scheme {:?}; the corresponding Cargo feature may need to be enabled", 
+            "no database driver found matching URL scheme {:?}; the corresponding Cargo feature may need to be enabled",
             database_url_parsed.scheme()
         ).into()),
         QueryDataSource::Cached(data) => {
@@ -316,6 +316,11 @@ where
 
                 record_tokens
             }
+            RecordType::Tuple => {
+                let columns = output::columns_to_rust::<DB>(&data.describe)?;
+
+                output::quote_query_tuple::<DB>(&input, &query_args, &columns)
+            }
             RecordType::Given(ref out_ty) => {
                 let columns = output::columns_to_rust::<DB>(&data.describe)?;
 
diff --git a/sqlx-macros-core/src/query/output.rs b/sqlx-macros-core/src/query/output.rs
index 5e7cc5058d..c2f5d2b7b6 100644
--- a/sqlx-macros-core/src/query/output.rs
+++ b/sqlx-macros-core/src/query/output.rs
@@ -191,6 +191,71 @@ pub fn quote_query_as<DB: DatabaseExt>(
     }
 }
 
+pub fn quote_query_tuple<DB: DatabaseExt>(
+    input: &QueryMacroInput,
+    bind_args: &Ident,
+    columns: &[RustColumn],
+) -> TokenStream {
+    let instantiations = columns.iter().enumerate().map(
+        |(
+            i,
+            RustColumn {
+                var_name, type_, ..
+            },
+        )| {
+            match (input.checked, type_) {
+                // we guarantee the type is valid so we can skip the runtime check
+                (true, ColumnType::Exact(type_)) => quote! {
+                    // binding to a `let` avoids confusing errors about
+                    // "try expression alternatives have incompatible types"
+                    // it doesn't seem to hurt inference in the other branches
+                    #[allow(non_snake_case)]
+                    let #var_name: #type_ = row.try_get_unchecked::<#type_, _>(#i)?.into();
+                },
+                // type was overridden to be a wildcard so we fallback to the runtime check
+                (true, ColumnType::Wildcard) => quote! (
+                #[allow(non_snake_case)]
+                let #var_name = row.try_get(#i)?;
+                ),
+                (true, ColumnType::OptWildcard) => {
+                    quote! (
+                    #[allow(non_snake_case)]
+                    let #var_name = row.try_get::<::std::option::Option<_>, _>(#i)?;
+                    )
+                }
+                // macro is the `_unchecked!()` variant so this will die in decoding if it's wrong
+                (false, _) => quote!(
+                #[allow(non_snake_case)]
+                let #var_name = row.try_get_unchecked(#i)?;
+                ),
+            }
+        },
+    );
+
+    let var_name = columns.iter().map(|col| &col.var_name);
+
+    let db_path = DB::db_path();
+    let row_path = DB::row_path();
+
+    // if this query came from a file, use `include_str!()` to tell the compiler where it came from
+    let sql = if let Some(ref path) = &input.file_path {
+        quote::quote_spanned! { input.src_span => include_str!(#path) }
+    } else {
+        let sql = &input.sql;
+        quote! { #sql }
+    };
+
+    quote! {
+        ::sqlx::__query_with_result::<#db_path, _>(#sql, #bind_args).try_map(|row: #row_path| {
+            use ::sqlx::Row as _;
+
+            #(#instantiations)*
+
+            ::std::result::Result::Ok((#(#var_name),*))
+        })
+    }
+}
+
 pub fn quote_query_scalar<DB: DatabaseExt>(
     input: &QueryMacroInput,
     bind_args: &Ident,
diff --git a/src/macros/mod.rs b/src/macros/mod.rs
index 7f8ff747f9..c7bcdf5eb0 100644
--- a/src/macros/mod.rs
+++ b/src/macros/mod.rs
@@ -48,7 +48,7 @@
 /// | At Least One   | `.fetch(...)`               | `impl Stream<Item = sqlx::Result<{adhoc struct}>>`  | Call `.try_next().await` to get each row result. |
 /// | Multiple   | `.fetch_all(...)`               | `sqlx::Result<Vec<{adhoc struct}>>`  | |
 ///
-/// \* All methods accept one of `&mut {connection type}`, `&mut Transaction` or `&Pool`.  
+/// \* All methods accept one of `&mut {connection type}`, `&mut Transaction` or `&Pool`.
 /// † Only callable if the query returns no columns; otherwise it's assumed the query *may* return at least one row.
 /// ## Requirements
 /// * The `DATABASE_URL` environment variable must be set at build-time to point to a database
@@ -675,6 +675,20 @@ macro_rules! query_scalar (
     )
 );
 
+/// A variant of [`query!`][`crate::query!`] which returns a tuple instead of an anonymous row type.
+///
+/// See [`query!`][`crate::query!`] for more information.
+#[macro_export]
+#[cfg_attr(docsrs, doc(cfg(feature = "macros")))]
+macro_rules! query_tuple (
+    ($query:expr) => (
+        $crate::sqlx_macros::expand_query!(tuple = _, source = $query)
+    );
+    ($query:expr, $($args:tt)*) => (
+        $crate::sqlx_macros::expand_query!(tuple = _, source = $query, args = [$($args)*])
+    )
+);
+
 /// A variant of [`query_scalar!`][`crate::query_scalar!`] which takes a file path like
 /// [`query_file!`][`crate::query_file!`].
 #[macro_export]

From 14dfae2529f9fef7047c1eb42a16b1b579ef68b6 Mon Sep 17 00:00:00 2001
From: Victor Nordam Suadicani <v.n.suadicani@gmail.com>
Date: Wed, 12 Mar 2025 17:02:49 +0100
Subject: [PATCH 2/2] Add simple test

---
 tests/postgres/macros.rs | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/tests/postgres/macros.rs b/tests/postgres/macros.rs
index 07ae962018..5c7cd9f0a4 100644
--- a/tests/postgres/macros.rs
+++ b/tests/postgres/macros.rs
@@ -21,6 +21,23 @@ async fn test_query() -> anyhow::Result<()> {
     Ok(())
 }
 
+#[sqlx_macros::test]
+async fn test_query_tuple() -> anyhow::Result<()> {
+    let mut conn = new::<Postgres>().await?;
+
+    let account = sqlx::query_tuple!(
+        "SELECT * from (VALUES (1, 'Herp Derpinson')) accounts(id, name) where id = $1",
+        1i32
+    )
+    .fetch_one(&mut conn)
+    .await?;
+
+    assert_eq!(account.0, Some(1));
+    assert_eq!(account.1.as_deref(), Some("Herp Derpinson"));
+
+    Ok(())
+}
+
 #[sqlx_macros::test]
 async fn test_non_null() -> anyhow::Result<()> {
     let mut conn = new::<Postgres>().await?;