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?;