From 2aab4cd2370f006820cf4b2f537d252c251dd7c3 Mon Sep 17 00:00:00 2001 From: Sean Aye Date: Tue, 28 Jan 2025 13:56:33 -0500 Subject: [PATCH] Add json(nullable) macro attribute (#3677) * add json optional attribute parser and expansion * rename attribute * add test * fix tests * fix lints * Add docs --- sqlx-core/src/from_row.rs | 26 +++++++++++++++++++ sqlx-macros-core/src/derives/attributes.rs | 25 ++++++++++++++----- sqlx-macros-core/src/derives/row.rs | 29 +++++++++++++++------- tests/mysql/macros.rs | 25 +++++++++++++++++++ 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/sqlx-core/src/from_row.rs b/sqlx-core/src/from_row.rs index 9c647d37..8776855d 100644 --- a/sqlx-core/src/from_row.rs +++ b/sqlx-core/src/from_row.rs @@ -271,6 +271,32 @@ use crate::{error::Error, row::Row}; /// } /// } /// ``` +/// +/// By default the `#[sqlx(json)]` attribute will assume that the underlying database row is +/// _not_ NULL. This can cause issues when your field type is an `Option` because this would be +/// represented as the _not_ NULL (in terms of DB) JSON value of `null`. +/// +/// If you wish to describe a database row which _is_ NULLable but _cannot_ contain the JSON value `null`, +/// use the `#[sqlx(json(nullable))]` attrubute. +/// +/// For example +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct Data { +/// field1: String, +/// field2: u64 +/// } +/// +/// #[derive(sqlx::FromRow)] +/// struct User { +/// id: i32, +/// name: String, +/// #[sqlx(json(nullable))] +/// metadata: Option +/// } +/// ``` +/// Would describe a database field which _is_ NULLable but if it exists it must be the JSON representation of `Data` +/// and cannot be the JSON value `null` pub trait FromRow<'r, R: Row>: Sized { fn from_row(row: &'r R) -> Result; } diff --git a/sqlx-macros-core/src/derives/attributes.rs b/sqlx-macros-core/src/derives/attributes.rs index cf18cffc..c6968790 100644 --- a/sqlx-macros-core/src/derives/attributes.rs +++ b/sqlx-macros-core/src/derives/attributes.rs @@ -1,8 +1,8 @@ use proc_macro2::{Ident, Span, TokenStream}; use quote::quote_spanned; use syn::{ - punctuated::Punctuated, token::Comma, Attribute, DeriveInput, Field, LitStr, Meta, Token, Type, - Variant, + parenthesized, punctuated::Punctuated, token::Comma, Attribute, DeriveInput, Field, LitStr, + Meta, Token, Type, Variant, }; macro_rules! assert_attribute { @@ -61,13 +61,18 @@ pub struct SqlxContainerAttributes { pub default: bool, } +pub enum JsonAttribute { + NonNullable, + Nullable, +} + pub struct SqlxChildAttributes { pub rename: Option, pub default: bool, pub flatten: bool, pub try_from: Option, pub skip: bool, - pub json: bool, + pub json: Option, } pub fn parse_container_attributes(input: &[Attribute]) -> syn::Result { @@ -144,7 +149,7 @@ pub fn parse_child_attributes(input: &[Attribute]) -> syn::Result syn::Result - (false, None, false) => { + (false, None, None) => { predicates .push(parse_quote!(#ty: ::sqlx::decode::Decode<#lifetime, R::Database>)); predicates.push(parse_quote!(#ty: ::sqlx::types::Type)); @@ -107,12 +107,12 @@ fn expand_derive_from_row_struct( parse_quote!(__row.try_get(#id_s)) } // Flatten - (true, None, false) => { + (true, None, None) => { predicates.push(parse_quote!(#ty: ::sqlx::FromRow<#lifetime, R>)); parse_quote!(<#ty as ::sqlx::FromRow<#lifetime, R>>::from_row(__row)) } // Flatten + Try from - (true, Some(try_from), false) => { + (true, Some(try_from), None) => { predicates.push(parse_quote!(#try_from: ::sqlx::FromRow<#lifetime, R>)); parse_quote!( <#try_from as ::sqlx::FromRow<#lifetime, R>>::from_row(__row) @@ -130,11 +130,11 @@ fn expand_derive_from_row_struct( ) } // Flatten + Json - (true, _, true) => { + (true, _, Some(_)) => { panic!("Cannot use both flatten and json") } // Try from - (false, Some(try_from), false) => { + (false, Some(try_from), None) => { predicates .push(parse_quote!(#try_from: ::sqlx::decode::Decode<#lifetime, R::Database>)); predicates.push(parse_quote!(#try_from: ::sqlx::types::Type)); @@ -154,8 +154,8 @@ fn expand_derive_from_row_struct( }) ) } - // Try from + Json - (false, Some(try_from), true) => { + // Try from + Json mandatory + (false, Some(try_from), Some(JsonAttribute::NonNullable)) => { predicates .push(parse_quote!(::sqlx::types::Json<#try_from>: ::sqlx::decode::Decode<#lifetime, R::Database>)); predicates.push(parse_quote!(::sqlx::types::Json<#try_from>: ::sqlx::types::Type)); @@ -175,14 +175,25 @@ fn expand_derive_from_row_struct( }) ) }, + // Try from + Json nullable + (false, Some(_), Some(JsonAttribute::Nullable)) => { + panic!("Cannot use both try from and json nullable") + }, // Json - (false, None, true) => { + (false, None, Some(JsonAttribute::NonNullable)) => { predicates .push(parse_quote!(::sqlx::types::Json<#ty>: ::sqlx::decode::Decode<#lifetime, R::Database>)); predicates.push(parse_quote!(::sqlx::types::Json<#ty>: ::sqlx::types::Type)); parse_quote!(__row.try_get::<::sqlx::types::Json<_>, _>(#id_s).map(|x| x.0)) }, + (false, None, Some(JsonAttribute::Nullable)) => { + predicates + .push(parse_quote!(::core::option::Option<::sqlx::types::Json<#ty>>: ::sqlx::decode::Decode<#lifetime, R::Database>)); + predicates.push(parse_quote!(::core::option::Option<::sqlx::types::Json<#ty>>: ::sqlx::types::Type)); + + parse_quote!(__row.try_get::<::core::option::Option<::sqlx::types::Json<_>>, _>(#id_s).map(|x| x.and_then(|y| y.0))) + }, }; if attributes.default { diff --git a/tests/mysql/macros.rs b/tests/mysql/macros.rs index f6bc7595..8187f6d8 100644 --- a/tests/mysql/macros.rs +++ b/tests/mysql/macros.rs @@ -494,6 +494,31 @@ async fn test_from_row_json_attr() -> anyhow::Result<()> { Ok(()) } +#[sqlx_macros::test] +async fn test_from_row_json_attr_nullable() -> anyhow::Result<()> { + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct J { + a: u32, + b: u32, + } + + #[derive(sqlx::FromRow)] + struct Record { + #[sqlx(json(nullable))] + j: Option, + } + + let mut conn = new::().await?; + + let record = sqlx::query_as::<_, Record>("select NULL as j") + .fetch_one(&mut conn) + .await?; + + assert!(record.j.is_none()); + Ok(()) +} + #[sqlx_macros::test] async fn test_from_row_json_try_from_attr() -> anyhow::Result<()> { #[derive(serde::Deserialize)]