Skip to content

Commit d0f6549

Browse files
authored
fix: allow LEFT JOIN to produce None for nested models (#2845)
* fix: handle nested Option<Model> in FromQueryResult * fix: fix test by aliasing columns * fix: fixes per feedback for optional nested model
1 parent 69b9b46 commit d0f6549

File tree

3 files changed

+113
-15
lines changed

3 files changed

+113
-15
lines changed

sea-orm-macros/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ proc-macro = true
2020
[dependencies]
2121
bae = { version = "0.2", package = "sea-bae", default-features = false, optional = true }
2222
heck = { version = "0.5", default-features = false }
23+
itertools = "0.14"
2324
pluralizer = { version = "0.5" }
2425
proc-macro-crate = { version = "3.2.0", optional = true }
2526
proc-macro2 = { version = "1", default-features = false }

sea-orm-macros/src/derives/model.rs

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use super::{
33
util::{escape_rust_keyword, field_not_ignored, trim_starting_raw_identifier},
44
};
55
use heck::ToUpperCamelCase;
6+
use itertools::izip;
67
use proc_macro2::TokenStream;
78
use quote::{format_ident, quote};
89
use std::iter::FromIterator;
@@ -103,28 +104,84 @@ impl DeriveModel {
103104
let ident = &self.ident;
104105
let field_idents = &self.field_idents;
105106
let column_idents = &self.column_idents;
106-
let field_values: Vec<TokenStream> = column_idents
107-
.iter()
108-
.zip(&self.ignore_attrs)
109-
.map(|(column_ident, ignore)| {
110-
if *ignore {
111-
quote! {
112-
Default::default()
113-
}
114-
} else {
115-
quote! {
116-
row.try_get(pre, sea_orm::IdenStatic::as_str(&<<Self as sea_orm::ModelTrait>::Entity as sea_orm::entity::EntityTrait>::Column::#column_ident).into())?
107+
let field_types = &self.field_types;
108+
let ignore_attrs = &self.ignore_attrs;
109+
110+
let (field_readers, field_values): (Vec<TokenStream>, Vec<TokenStream>) = izip!(
111+
field_idents.iter(),
112+
column_idents,
113+
field_types,
114+
ignore_attrs,
115+
)
116+
.map(|(field_ident, column_ident, field_type, &ignore)| {
117+
if ignore {
118+
let reader = quote! {
119+
let #field_ident: Option<()> = None;
120+
};
121+
let unwrapper = quote! {
122+
#field_ident: Default::default()
123+
};
124+
(reader, unwrapper)
125+
} else {
126+
let reader = quote! {
127+
let #field_ident =
128+
row.try_get_nullable::<Option<#field_type>>(
129+
pre,
130+
sea_orm::IdenStatic::as_str(
131+
&<<Self as sea_orm::ModelTrait>::Entity
132+
as sea_orm::entity::EntityTrait>::Column::#column_ident
133+
).into()
134+
)?;
135+
};
136+
let unwrapper = quote! {
137+
#field_ident: #field_ident.ok_or_else(|| sea_orm::DbErr::Type(
138+
format!(
139+
"Missing value for column '{}'",
140+
sea_orm::IdenStatic::as_str(
141+
&<<Self as sea_orm::ModelTrait>::Entity
142+
as sea_orm::entity::EntityTrait>::Column::#column_ident
143+
)
144+
)
145+
))?
146+
};
147+
(reader, unwrapper)
148+
}
149+
})
150+
.unzip();
151+
152+
// When a nested model is loaded via LEFT JOIN, all its fields may be NULL.
153+
// In that case we interpret it as "no nested row" (i.e., Option::None).
154+
// This check detects that condition by testing if all non-ignored fields are NULL.
155+
let all_null_check = {
156+
let checks: Vec<_> = izip!(field_idents, ignore_attrs)
157+
.filter_map(|(field_ident, &ignore)| {
158+
if ignore {
159+
None
160+
} else {
161+
Some(quote! { #field_ident.is_none() })
117162
}
118-
}
119-
})
120-
.collect();
163+
})
164+
.collect();
165+
166+
quote! { true #( && #checks )* }
167+
};
121168

122169
quote!(
123170
#[automatically_derived]
124171
impl sea_orm::FromQueryResult for #ident {
125172
fn from_query_result(row: &sea_orm::QueryResult, pre: &str) -> std::result::Result<Self, sea_orm::DbErr> {
173+
Self::from_query_result_nullable(row, pre).map_err(Into::into)
174+
}
175+
176+
fn from_query_result_nullable(row: &sea_orm::QueryResult, pre: &str) -> std::result::Result<Self, sea_orm::TryGetError> {
177+
#(#field_readers)*
178+
179+
if #all_null_check {
180+
return Err(sea_orm::TryGetError::Null("All fields of nested model are null".into()));
181+
}
182+
126183
Ok(Self {
127-
#(#field_idents: #field_values),*
184+
#(#field_values),*
128185
})
129186
}
130187
}

tests/from_query_result_tests.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ struct BakeryFlat {
4949
profit: f64,
5050
}
5151

52+
#[derive(FromQueryResult)]
53+
struct CakeWithOptionalBakeryModel {
54+
#[sea_orm(alias = "cake_id")]
55+
id: i32,
56+
#[sea_orm(alias = "cake_name")]
57+
name: String,
58+
#[sea_orm(nested)]
59+
bakery: Option<bakery::Model>,
60+
}
61+
5262
#[sea_orm_macros::test]
5363
async fn from_query_result_left_join_does_not_exist() {
5464
let ctx = TestContext::new("from_query_result_left_join_does_not_exist").await;
@@ -77,6 +87,36 @@ async fn from_query_result_left_join_does_not_exist() {
7787
ctx.delete().await;
7888
}
7989

90+
#[sea_orm_macros::test]
91+
async fn from_query_result_left_join_with_optional_model_does_not_exist() {
92+
let ctx =
93+
TestContext::new("from_query_result_left_join_with_optional_model_does_not_exist").await;
94+
create_tables(&ctx.db).await.unwrap();
95+
96+
seed_data::init_1(&ctx, false).await;
97+
98+
let cake: CakeWithOptionalBakeryModel = cake::Entity::find()
99+
.select_only()
100+
.column_as(cake::Column::Id, "cake_id")
101+
.column_as(cake::Column::Name, "cake_name")
102+
.column(bakery::Column::Id)
103+
.column(bakery::Column::Name)
104+
.column(bakery::Column::ProfitMargin)
105+
.left_join(bakery::Entity)
106+
.order_by_asc(cake::Column::Id)
107+
.into_model()
108+
.one(&ctx.db)
109+
.await
110+
.expect("succeeds to get the result")
111+
.expect("exactly one model in DB");
112+
113+
assert_eq!(cake.id, 13);
114+
assert_eq!(cake.name, "Cheesecake");
115+
assert!(cake.bakery.is_none());
116+
117+
ctx.delete().await;
118+
}
119+
80120
#[sea_orm_macros::test]
81121
async fn from_query_result_left_join_exists() {
82122
let ctx = TestContext::new("from_query_result_left_join_exists").await;

0 commit comments

Comments
 (0)