From fdb8d3adb3d7edfcde5b93e92ef310d4acd99824 Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Mon, 6 Apr 2026 18:06:46 +0800 Subject: [PATCH 1/3] Fix nested PartialModel null detection for optional fields --- sea-orm-macros/src/derives/model.rs | 56 +++++++++++++++++++++-- sea-orm-sync/tests/partial_model_tests.rs | 31 +++++++++++++ tests/partial_model_tests.rs | 32 +++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/sea-orm-macros/src/derives/model.rs b/sea-orm-macros/src/derives/model.rs index 31e7e66c11..f32496804a 100644 --- a/sea-orm-macros/src/derives/model.rs +++ b/sea-orm-macros/src/derives/model.rs @@ -7,7 +7,7 @@ use itertools::izip; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use std::iter::FromIterator; -use syn::{Attribute, Data, Expr, Ident, LitStr}; +use syn::{Attribute, Data, Expr, Ident, LitStr, Type}; pub(crate) struct DeriveModel { column_idents: Vec, @@ -100,6 +100,54 @@ impl DeriveModel { ])) } + fn option_nesting_depth(ty: &Type) -> usize { + match ty { + Type::Path(type_path) if type_path.qself.is_none() => type_path + .path + .segments + .last() + .and_then(|segment| { + if segment.ident != "Option" { + return None; + } + + match &segment.arguments { + syn::PathArguments::AngleBracketed(args) if args.args.len() == 1 => { + args.args.first().map(|arg| match arg { + syn::GenericArgument::Type(inner) => { + 1 + Self::option_nesting_depth(inner) + } + _ => 1, + }) + } + _ => Some(1), + } + }) + .unwrap_or(0), + _ => 0, + } + } + + fn null_check_expr(field_ident: &Ident, field_type: &Type) -> TokenStream { + let depth = Self::option_nesting_depth(field_type); + + if depth == 0 { + return quote! { #field_ident.is_none() }; + } + + let patterns: Vec<_> = (0..=depth) + .map(|depth| { + let mut pattern = quote! { None }; + for _ in 0..depth { + pattern = quote! { Some(#pattern) }; + } + pattern + }) + .collect(); + + quote! { matches!(#field_ident, #( #patterns )|* ) } + } + fn impl_from_query_result(&self) -> TokenStream { let ident = &self.ident; let field_idents = &self.field_idents; @@ -153,12 +201,12 @@ impl DeriveModel { // In that case we interpret it as "no nested row" (i.e., Option::None). // This check detects that condition by testing if all non-ignored fields are NULL. let all_null_check = { - let checks: Vec<_> = izip!(field_idents, ignore_attrs) - .filter_map(|(field_ident, &ignore)| { + let checks: Vec<_> = izip!(field_idents, field_types, ignore_attrs) + .filter_map(|(field_ident, field_type, &ignore)| { if ignore { None } else { - Some(quote! { #field_ident.is_none() }) + Some(Self::null_check_expr(field_ident, field_type)) } }) .collect(); diff --git a/sea-orm-sync/tests/partial_model_tests.rs b/sea-orm-sync/tests/partial_model_tests.rs index 359586b308..1813b47651 100644 --- a/sea-orm-sync/tests/partial_model_tests.rs +++ b/sea-orm-sync/tests/partial_model_tests.rs @@ -130,6 +130,37 @@ fn partial_model_left_join_does_not_exist() { ctx.delete(); } +#[sea_orm_macros::test] +fn partial_model_left_join_with_optional_nested_model_optional_fields_does_not_exist() { + #[derive(Debug, DerivePartialModel)] + #[sea_orm(entity = "cake::Entity")] + struct CakeWithOptionalBakerModel { + id: i32, + name: String, + #[sea_orm(nested)] + baker: Option, + } + + let ctx = TestContext::new("partial_model_left_join_optional_baker_model"); + create_tables(&ctx.db).unwrap(); + + seed_data::init_1(&ctx, false); + + let cake: CakeWithOptionalBakerModel = cake::Entity::find() + .left_join(baker::Entity) + .order_by_asc(cake::Column::Id) + .into_partial_model() + .one(&ctx.db) + .expect("succeeds to get the result") + .expect("exactly one model in DB"); + + assert_eq!(cake.id, 13); + assert_eq!(cake.name, "Cheesecake"); + assert!(cake.baker.is_none()); + + ctx.delete(); +} + #[sea_orm_macros::test] fn partial_model_left_join_exists() { let ctx = TestContext::new("partial_model_left_join_exists"); diff --git a/tests/partial_model_tests.rs b/tests/partial_model_tests.rs index 87be6d7e08..7ad220f74e 100644 --- a/tests/partial_model_tests.rs +++ b/tests/partial_model_tests.rs @@ -131,6 +131,38 @@ async fn partial_model_left_join_does_not_exist() { ctx.delete().await; } +#[sea_orm_macros::test] +async fn partial_model_left_join_with_optional_nested_model_optional_fields_does_not_exist() { + #[derive(Debug, DerivePartialModel)] + #[sea_orm(entity = "cake::Entity")] + struct CakeWithOptionalBakerModel { + id: i32, + name: String, + #[sea_orm(nested)] + baker: Option, + } + + let ctx = TestContext::new("partial_model_left_join_optional_baker_model").await; + create_tables(&ctx.db).await.unwrap(); + + seed_data::init_1(&ctx, false).await; + + let cake: CakeWithOptionalBakerModel = cake::Entity::find() + .left_join(baker::Entity) + .order_by_asc(cake::Column::Id) + .into_partial_model() + .one(&ctx.db) + .await + .expect("succeeds to get the result") + .expect("exactly one model in DB"); + + assert_eq!(cake.id, 13); + assert_eq!(cake.name, "Cheesecake"); + assert!(cake.baker.is_none()); + + ctx.delete().await; +} + #[sea_orm_macros::test] async fn partial_model_left_join_exists() { let ctx = TestContext::new("partial_model_left_join_exists").await; From 54870a6f28a0411deeb65a5f1a30060842ce8103 Mon Sep 17 00:00:00 2001 From: Huliiiiii <308013446a@gmail.com> Date: Tue, 7 Apr 2026 03:34:49 +0800 Subject: [PATCH 2/3] Add comments and refactor --- sea-orm-macros/src/derives/model.rs | 114 ++++++++++++++++------------ 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/sea-orm-macros/src/derives/model.rs b/sea-orm-macros/src/derives/model.rs index f32496804a..79ae4a9339 100644 --- a/sea-orm-macros/src/derives/model.rs +++ b/sea-orm-macros/src/derives/model.rs @@ -100,54 +100,6 @@ impl DeriveModel { ])) } - fn option_nesting_depth(ty: &Type) -> usize { - match ty { - Type::Path(type_path) if type_path.qself.is_none() => type_path - .path - .segments - .last() - .and_then(|segment| { - if segment.ident != "Option" { - return None; - } - - match &segment.arguments { - syn::PathArguments::AngleBracketed(args) if args.args.len() == 1 => { - args.args.first().map(|arg| match arg { - syn::GenericArgument::Type(inner) => { - 1 + Self::option_nesting_depth(inner) - } - _ => 1, - }) - } - _ => Some(1), - } - }) - .unwrap_or(0), - _ => 0, - } - } - - fn null_check_expr(field_ident: &Ident, field_type: &Type) -> TokenStream { - let depth = Self::option_nesting_depth(field_type); - - if depth == 0 { - return quote! { #field_ident.is_none() }; - } - - let patterns: Vec<_> = (0..=depth) - .map(|depth| { - let mut pattern = quote! { None }; - for _ in 0..depth { - pattern = quote! { Some(#pattern) }; - } - pattern - }) - .collect(); - - quote! { matches!(#field_ident, #( #patterns )|* ) } - } - fn impl_from_query_result(&self) -> TokenStream { let ident = &self.ident; let field_idents = &self.field_idents; @@ -206,7 +158,7 @@ impl DeriveModel { if ignore { None } else { - Some(Self::null_check_expr(field_ident, field_type)) + Some(create_is_null_expr(field_ident, field_type)) } }) .collect(); @@ -306,3 +258,67 @@ pub fn expand_derive_model( ) -> syn::Result { DeriveModel::new(ident, data, attrs)?.expand() } + +/// Get the total nesting depth of `Option`. +/// +/// For example: +/// - `Option` => `1` +/// - `Option>` => `2` +/// - `Option>>` => `3` +fn option_nesting_depth(ty: &Type) -> usize { + match ty { + Type::Path(type_path) if type_path.qself.is_none() => type_path + .path + .segments + .last() + .and_then(|segment| { + if segment.ident != "Option" { + return None; + } + + match &segment.arguments { + syn::PathArguments::AngleBracketed(args) if args.args.len() == 1 => { + args.args.first().map(|arg| match arg { + syn::GenericArgument::Type(inner) => 1 + option_nesting_depth(inner), + _ => 1, + }) + } + _ => Some(1), + } + }) + .unwrap_or(0), + _ => 0, + } +} + +/// Generate an expr that checks whether an optional field is nullish. +/// +/// For a nested `Option`, the generated expression treats every partially +/// unwrapped `None` as null. +/// +/// For example, for `Option>>`, it will generate: +/// ``` +/// matches!( +/// field, +/// None | Some(None) | Some(Some(None)) | Some(Some(Some(None))) +/// ) +/// ``` +fn create_is_null_expr(field_ident: &Ident, field_type: &Type) -> TokenStream { + let depth = option_nesting_depth(field_type); + + if depth == 0 { + return quote! { #field_ident.is_none() }; + } + + let patterns: Vec<_> = (0..=depth) + .map(|depth| { + let mut pattern = quote! { None }; + for _ in 0..depth { + pattern = quote! { Some(#pattern) }; + } + pattern + }) + .collect(); + + quote! { matches!(#field_ident, #( #patterns )|* ) } +} From 31d990ca40fde104065af54ea26a9500e3de0cd6 Mon Sep 17 00:00:00 2001 From: WaterWhisperer Date: Tue, 7 Apr 2026 10:12:17 +0800 Subject: [PATCH 3/3] Update the test to cover a deeper nested partial model --- sea-orm-macros/src/derives/model.rs | 2 +- sea-orm-sync/tests/partial_model_tests.rs | 54 ++++++++++++++++++----- tests/partial_model_tests.rs | 54 ++++++++++++++++++----- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/sea-orm-macros/src/derives/model.rs b/sea-orm-macros/src/derives/model.rs index 79ae4a9339..d29a69b062 100644 --- a/sea-orm-macros/src/derives/model.rs +++ b/sea-orm-macros/src/derives/model.rs @@ -297,7 +297,7 @@ fn option_nesting_depth(ty: &Type) -> usize { /// unwrapped `None` as null. /// /// For example, for `Option>>`, it will generate: -/// ``` +/// ```rust,ignore /// matches!( /// field, /// None | Some(None) | Some(Some(None)) | Some(Some(Some(None))) diff --git a/sea-orm-sync/tests/partial_model_tests.rs b/sea-orm-sync/tests/partial_model_tests.rs index 1813b47651..5d5de7c65d 100644 --- a/sea-orm-sync/tests/partial_model_tests.rs +++ b/sea-orm-sync/tests/partial_model_tests.rs @@ -132,31 +132,63 @@ fn partial_model_left_join_does_not_exist() { #[sea_orm_macros::test] fn partial_model_left_join_with_optional_nested_model_optional_fields_does_not_exist() { - #[derive(Debug, DerivePartialModel)] + #[derive(Debug, DerivePartialModel, PartialEq)] + #[sea_orm(entity = "baker::Entity")] + struct BakerDetails { + id: i32, + name: String, + bakery_id: Option, + } + + #[derive(Debug, DerivePartialModel, PartialEq)] + #[sea_orm(entity = "baker::Entity")] + struct NestedBaker { + #[sea_orm(nested)] + details: BakerDetails, + } + + #[derive(Debug, DerivePartialModel, PartialEq)] #[sea_orm(entity = "cake::Entity")] struct CakeWithOptionalBakerModel { id: i32, name: String, #[sea_orm(nested)] - baker: Option, + baker: Option, } - let ctx = TestContext::new("partial_model_left_join_optional_baker_model"); + let ctx = TestContext::new("partial_model_left_join_deep_baker"); create_tables(&ctx.db).unwrap(); - seed_data::init_1(&ctx, false); + seed_data::init_1(&ctx, true); - let cake: CakeWithOptionalBakerModel = cake::Entity::find() + let cakes: Vec = cake::Entity::find() .left_join(baker::Entity) .order_by_asc(cake::Column::Id) .into_partial_model() - .one(&ctx.db) - .expect("succeeds to get the result") - .expect("exactly one model in DB"); + .all(&ctx.db) + .expect("succeeds to get the result"); - assert_eq!(cake.id, 13); - assert_eq!(cake.name, "Cheesecake"); - assert!(cake.baker.is_none()); + assert_eq!( + cakes, + [ + CakeWithOptionalBakerModel { + id: 13, + name: "Cheesecake".to_owned(), + baker: Some(NestedBaker { + details: BakerDetails { + id: 22, + name: "Master Baker".to_owned(), + bakery_id: Some(42), + }, + }), + }, + CakeWithOptionalBakerModel { + id: 15, + name: "Chocolate".to_owned(), + baker: None, + }, + ] + ); ctx.delete(); } diff --git a/tests/partial_model_tests.rs b/tests/partial_model_tests.rs index 7ad220f74e..3a3f629049 100644 --- a/tests/partial_model_tests.rs +++ b/tests/partial_model_tests.rs @@ -133,32 +133,64 @@ async fn partial_model_left_join_does_not_exist() { #[sea_orm_macros::test] async fn partial_model_left_join_with_optional_nested_model_optional_fields_does_not_exist() { - #[derive(Debug, DerivePartialModel)] + #[derive(Debug, DerivePartialModel, PartialEq)] + #[sea_orm(entity = "baker::Entity")] + struct BakerDetails { + id: i32, + name: String, + bakery_id: Option, + } + + #[derive(Debug, DerivePartialModel, PartialEq)] + #[sea_orm(entity = "baker::Entity")] + struct NestedBaker { + #[sea_orm(nested)] + details: BakerDetails, + } + + #[derive(Debug, DerivePartialModel, PartialEq)] #[sea_orm(entity = "cake::Entity")] struct CakeWithOptionalBakerModel { id: i32, name: String, #[sea_orm(nested)] - baker: Option, + baker: Option, } - let ctx = TestContext::new("partial_model_left_join_optional_baker_model").await; + let ctx = TestContext::new("partial_model_left_join_deep_baker").await; create_tables(&ctx.db).await.unwrap(); - seed_data::init_1(&ctx, false).await; + seed_data::init_1(&ctx, true).await; - let cake: CakeWithOptionalBakerModel = cake::Entity::find() + let cakes: Vec = cake::Entity::find() .left_join(baker::Entity) .order_by_asc(cake::Column::Id) .into_partial_model() - .one(&ctx.db) + .all(&ctx.db) .await - .expect("succeeds to get the result") - .expect("exactly one model in DB"); + .expect("succeeds to get the result"); - assert_eq!(cake.id, 13); - assert_eq!(cake.name, "Cheesecake"); - assert!(cake.baker.is_none()); + assert_eq!( + cakes, + [ + CakeWithOptionalBakerModel { + id: 13, + name: "Cheesecake".to_owned(), + baker: Some(NestedBaker { + details: BakerDetails { + id: 22, + name: "Master Baker".to_owned(), + bakery_id: Some(42), + }, + }), + }, + CakeWithOptionalBakerModel { + id: 15, + name: "Chocolate".to_owned(), + baker: None, + }, + ] + ); ctx.delete().await; }