Add json(nullable) macro attribute (#3677)

* add json optional attribute parser and expansion

* rename attribute

* add test

* fix tests

* fix lints

* Add docs
This commit is contained in:
Sean Aye 2025-01-28 13:56:33 -05:00 committed by GitHub
parent 546ec960a9
commit 2aab4cd237
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 90 additions and 15 deletions

View File

@ -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<T>` 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<Data>
/// }
/// ```
/// 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<Self, Error>;
}

View File

@ -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<String>,
pub default: bool,
pub flatten: bool,
pub try_from: Option<Type>,
pub skip: bool,
pub json: bool,
pub json: Option<JsonAttribute>,
}
pub fn parse_container_attributes(input: &[Attribute]) -> syn::Result<SqlxContainerAttributes> {
@ -144,7 +149,7 @@ pub fn parse_child_attributes(input: &[Attribute]) -> syn::Result<SqlxChildAttri
let mut try_from = None;
let mut flatten = false;
let mut skip: bool = false;
let mut json = false;
let mut json = None;
for attr in input.iter().filter(|a| a.path().is_ident("sqlx")) {
attr.parse_nested_meta(|meta| {
@ -163,13 +168,21 @@ pub fn parse_child_attributes(input: &[Attribute]) -> syn::Result<SqlxChildAttri
} else if meta.path.is_ident("skip") {
skip = true;
} else if meta.path.is_ident("json") {
json = true;
if meta.input.peek(syn::token::Paren) {
let content;
parenthesized!(content in meta.input);
let literal: Ident = content.parse()?;
assert_eq!(literal.to_string(), "nullable", "Unrecognized `json` attribute. Valid values are `json` or `json(nullable)`");
json = Some(JsonAttribute::Nullable);
} else {
json = Some(JsonAttribute::NonNullable);
}
}
Ok(())
})?;
if json && flatten {
if json.is_some() && flatten {
fail!(
attr,
"Cannot use `json` and `flatten` together on the same field"

View File

@ -6,7 +6,7 @@ use syn::{
};
use super::{
attributes::{parse_child_attributes, parse_container_attributes},
attributes::{parse_child_attributes, parse_container_attributes, JsonAttribute},
rename_all,
};
@ -99,7 +99,7 @@ fn expand_derive_from_row_struct(
let expr: Expr = match (attributes.flatten, attributes.try_from, attributes.json) {
// <No attributes>
(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<R::Database>));
@ -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<R::Database>));
@ -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<R::Database>));
@ -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<R::Database>));
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<R::Database>));
parse_quote!(__row.try_get::<::core::option::Option<::sqlx::types::Json<_>>, _>(#id_s).map(|x| x.and_then(|y| y.0)))
},
};
if attributes.default {

View File

@ -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<J>,
}
let mut conn = new::<MySql>().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)]