Skip to content

Commit f0121e0

Browse files
authored
belongs_to detach: null the FK if nullable, error if not (#3127)
* belongs_to detach: null the FK if nullable, clean error if not `delete_<field>()` on a belongs_to (`ActiveHasOne::Delete`) was silently ignored on save β€” `take()` mapped `Delete` to `None`, so the branch did nothing and the foreign key was never touched. Handle it in the belongs_to save action: `clear_parent_key::<Related>()` nulls this row's FK when the column is nullable, and returns `false` when it is not β€” in which case we return a clean `DbErr::Type` ("relation cannot be detached") instead of attempting an UPDATE that a raw NOT NULL constraint would reject. Composite FKs are handled by the relation def. Also make `clear_key_on_active_model` a no-op when the FK column was never set (a freshly-built ActiveModel) rather than erroring, so `delete_<field>()` on a fresh model just inserts NULL. Keeps the single `ActiveHasOne` type β€” nullability drives behavior at runtime rather than being encoded in the type. Adds tests for nullable detach, non-nullable clean error, and detach-on-unset no-op. * Use if-let over single-arm match in clear_key_on_active_model (clippy) * Support nested writes for duplicate-target belongs_to When two belongs_to fields share a target entity (e.g. `user_follower.user` + `user_follower.follower`, both -> `user`), there is no unique `Related<E>` to key the FK write by, so the save action's `entity_count == 1` guard dropped them entirely β€” nested writes silently no-op'd. Route such relations through the relation-keyed helpers instead: capture their relation variant at detection time (the explicit `relation_enum`, else the default inferred the same way the loader/model_ex does) and use `set_parent_key_for` / `clear_parent_key_for(Relation::X)`. Unique-target belongs_to keep the entity-keyed path unchanged. Adds `ActiveModelTrait::clear_parent_key_for` (the relation-keyed counterpart to `clear_parent_key`, mirroring `set_parent_key_for`). Adds `test_belongs_to_duplicate_target`.
1 parent 90d6414 commit f0121e0

8 files changed

Lines changed: 426 additions & 33 deletions

File tree

β€Žsea-orm-macros/src/derives/active_model_ex.rsβ€Ž

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use super::active_model::DeriveActiveModel;
22
use super::attributes::compound_attr;
3+
use super::model_ex::infer_relation_name_from_entity;
34
use super::util::{extract_compound_entity, field_not_ignored_compound, is_compound_field};
4-
use proc_macro2::{Ident, TokenStream};
5+
use heck::ToUpperCamelCase;
6+
use proc_macro2::{Ident, Span, TokenStream};
57
use quote::{format_ident, quote};
68
use std::collections::HashMap;
79
use syn::{Attribute, Data, Expr, Fields, LitStr, Type, Visibility};
@@ -98,11 +100,29 @@ pub fn expand_derive_active_model_ex(
98100
{
99101
has_many_self_fields
100102
.push((ident.clone(), relation_enum.clone()));
103+
} else if compound_attrs.belongs_to.is_some() {
104+
// A belongs_to sharing its target entity with another
105+
// relation, disambiguated by an explicit relation_enum:
106+
// key the FK write by that relation variant.
107+
let variant = Ident::new(
108+
&relation_enum.value().to_upper_camel_case(),
109+
relation_enum.span(),
110+
);
111+
belongs_to_fields.push((
112+
ident.clone(),
113+
entity_path.to_owned(),
114+
Some(variant),
115+
));
101116
}
102117
} else if *entity_count.get(entity_path).unwrap() == 1 {
103118
// can only Related to another Entity once
104119
if compound_attrs.belongs_to.is_some() {
105-
belongs_to_fields.push(ident.clone());
120+
// Unique target: key the FK write by entity.
121+
belongs_to_fields.push((
122+
ident.clone(),
123+
entity_path.to_owned(),
124+
None,
125+
));
106126
} else if compound_attrs.has_one.is_some() {
107127
has_one_fields.push(ident.clone());
108128
} else if compound_attrs.has_many.is_some()
@@ -117,6 +137,20 @@ pub fn expand_derive_active_model_ex(
117137
compound_attrs.via.as_ref().unwrap().value(),
118138
));
119139
}
140+
} else if compound_attrs.belongs_to.is_some() {
141+
// A belongs_to sharing its target entity with another
142+
// relation but without an explicit relation_enum: key the
143+
// FK write by the inferred (default) relation variant.
144+
let variant = Ident::new(
145+
&infer_relation_name_from_entity(entity_path)
146+
.to_upper_camel_case(),
147+
Span::call_site(),
148+
);
149+
belongs_to_fields.push((
150+
ident.clone(),
151+
entity_path.to_owned(),
152+
Some(variant),
153+
));
120154
}
121155
if compound_attrs.self_ref.is_some()
122156
&& compound_attrs.via.is_some()
@@ -326,7 +360,7 @@ pub fn expand_derive_active_model_ex(
326360
}
327361

328362
fn expand_active_model_action(
329-
belongs_to: &[Ident],
363+
belongs_to: &[(Ident, String, Option<Ident>)],
330364
belongs_to_self: &[(Ident, LitStr)],
331365
has_one: &[Ident],
332366
has_many: &[Ident],
@@ -353,12 +387,35 @@ fn expand_active_model_action(
353387
quote!()
354388
};
355389

356-
for field in belongs_to {
390+
for (field, related_entity, relation_variant) in belongs_to {
391+
let related_entity: TokenStream = related_entity.parse().unwrap();
392+
// Disambiguate the FK write. A relation sharing its target entity with another
393+
// is keyed by its relation variant (there is no unique `Related<E>` to key by);
394+
// a unique target is keyed by entity.
395+
let (set_parent_key, clear_parent_key) = match relation_variant {
396+
Some(variant) => (
397+
quote!(self.set_parent_key_for(&model, Relation::#variant)?),
398+
quote!(self.clear_parent_key_for(Relation::#variant)?),
399+
),
400+
None => (
401+
quote!(self.set_parent_key(&model)?),
402+
quote!(self.clear_parent_key::<#related_entity>()?),
403+
),
404+
};
357405
belongs_to_action.extend(quote! {
406+
// Detach: `Delete` on a belongs_to nulls this row's own foreign key.
407+
// If the FK is not nullable it cannot be detached β€” return a clean error
408+
// instead of a raw database constraint violation.
409+
if self.#field.is_delete() && !#clear_parent_key {
410+
return Err(sea_orm::DbErr::Type(format!(
411+
"Relation `{}` cannot be detached: its foreign key is not nullable",
412+
stringify!(#field)
413+
)));
414+
}
358415
let #field = if let Some(model) = self.#field.take() {
359416
if model.is_update() {
360417
// has primary key
361-
self.set_parent_key(&model)?;
418+
#set_parent_key;
362419
if model.is_changed() {
363420
let model = #box_pin(model.action(action, db))#await_?;
364421
Some(model)
@@ -368,7 +425,7 @@ fn expand_active_model_action(
368425
} else {
369426
// new model
370427
let model = #box_pin(model.action(action, db))#await_?;
371-
self.set_parent_key(&model)?;
428+
#set_parent_key;
372429
Some(model)
373430
}
374431
} else {

β€Žsea-orm-macros/src/derives/model_ex.rsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,7 @@ fn get_related<'a>(attr: &compound_attr::SeaOrm, ty: &'a str) -> (&'a str, Ident
730730
(related_entity, relation_enum)
731731
}
732732

733-
fn infer_relation_name_from_entity(s: &str) -> &str {
733+
pub(crate) fn infer_relation_name_from_entity(s: &str) -> &str {
734734
let s = s.trim_end_matches("::Entity");
735735
if let Some((_, suffix)) = s.rsplit_once("::") {
736736
return suffix;

β€Žsea-orm-sync/src/entity/active_model.rsβ€Ž

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,22 @@ pub trait ActiveModelTrait: Clone + Debug {
782782
clear_key_on_active_model(&rel_def.from_col, self)
783783
}
784784

785+
#[doc(hidden)]
786+
/// Clear a specific belongs_to relation, keyed by relation (to disambiguate
787+
/// multiple relations to the same entity), if it is optional; return true.
788+
fn clear_parent_key_for(
789+
&mut self,
790+
rel: <Self::Entity as EntityTrait>::Relation,
791+
) -> Result<bool, DbErr> {
792+
let rel_def = rel.def();
793+
794+
if rel_def.is_owner {
795+
return Err(DbErr::Type(format!("Relation {rel:?} is not belongs_to")));
796+
}
797+
798+
clear_key_on_active_model(&rel_def.from_col, self)
799+
}
800+
785801
#[doc(hidden)]
786802
fn clear_parent_key_for_self_rev(
787803
&mut self,

β€Žsea-orm-sync/src/query/util.rsβ€Ž

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,13 @@ where
126126
if !column.def().is_null() {
127127
return Ok(false);
128128
}
129-
model.set(
130-
column,
131-
match model.get(column).into_value() {
132-
Some(value) => value.as_null(),
133-
None => {
134-
return Err(DbErr::AttrNotSet(format!(
135-
"{}.{}",
136-
<ActiveModel::Entity as Default>::default().as_str(),
137-
col_name
138-
)));
139-
}
140-
},
141-
);
129+
// Null out the key column so the detach is persisted. If it was never set
130+
// (a freshly-built ActiveModel) there is nothing to clear β€” it will insert as
131+
// NULL / stay absent on update β€” so treat it as already cleared rather than
132+
// erroring.
133+
if let Some(value) = model.get(column).into_value() {
134+
model.set(column, value.as_null());
135+
}
142136
}
143137

144138
Ok(true)

β€Žsea-orm-sync/tests/active_model_ex_tests.rsβ€Ž

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,3 +775,152 @@ fn test_has_one_replace_and_delete() -> Result<(), DbErr> {
775775

776776
Ok(())
777777
}
778+
779+
#[sea_orm_macros::test]
780+
fn test_belongs_to_nullable_detach() -> Result<(), DbErr> {
781+
use common::bakery_dense::{bakery, cake};
782+
783+
let ctx = TestContext::new("test_belongs_to_nullable_detach");
784+
let db = &ctx.db;
785+
786+
db.get_schema_builder()
787+
.register(bakery::Entity)
788+
.register(cake::Entity)
789+
.apply(db)?;
790+
791+
info!("attach a cake to a bakery through the nullable belongs_to");
792+
let cake = cake::ActiveModel::builder()
793+
.set_name("Cheesecake")
794+
.set_price(Decimal::from(10))
795+
.set_gluten_free(false)
796+
.set_serial(Uuid::nil())
797+
.set_bakery(
798+
bakery::ActiveModel::builder()
799+
.set_name("SeaSide")
800+
.set_profit_margin(10.0),
801+
)
802+
.save(db)?;
803+
assert!(cake::Entity::find().one(db)?.unwrap().bakery_id.is_some());
804+
805+
info!("delete_<field> on a nullable belongs_to nulls the FK and keeps both rows");
806+
cake.delete_bakery().save(db)?;
807+
808+
let reloaded = cake::Entity::find().one(db)?.unwrap();
809+
assert!(reloaded.bakery_id.is_none());
810+
assert_eq!(bakery::Entity::find().all(db)?.len(), 1);
811+
812+
ctx.delete();
813+
814+
Ok(())
815+
}
816+
817+
#[sea_orm_macros::test]
818+
fn test_belongs_to_non_null_detach_errors() -> Result<(), DbErr> {
819+
use common::blogger::*;
820+
821+
let ctx = TestContext::new("test_belongs_to_non_null_detach_errors");
822+
let db = &ctx.db;
823+
824+
db.get_schema_builder()
825+
.register(user::Entity)
826+
.register(post::Entity)
827+
.apply(db)?;
828+
829+
let user = user::ActiveModel::builder()
830+
.set_name("Alice")
831+
.set_email("alice@sea-ql.org")
832+
.save(db)?;
833+
let post = post::ActiveModel::builder()
834+
.set_title("post 1")
835+
.set_author(user)
836+
.save(db)?;
837+
838+
info!("detaching a non-nullable belongs_to (post.author) returns a clean error");
839+
let err = post.delete_author().save(db).unwrap_err();
840+
assert!(
841+
matches!(err, DbErr::Type(_)),
842+
"expected a clean DbErr::Type, got {err:?}"
843+
);
844+
845+
// The row is untouched β€” nothing was deleted or nulled.
846+
assert!(post::Entity::find().one(db)?.is_some());
847+
848+
ctx.delete();
849+
850+
Ok(())
851+
}
852+
853+
#[sea_orm_macros::test]
854+
fn test_detach_unset_belongs_to_is_noop() -> Result<(), DbErr> {
855+
use common::bakery_dense::{bakery, cake};
856+
857+
let ctx = TestContext::new("test_detach_unset_belongs_to_is_noop");
858+
let db = &ctx.db;
859+
860+
db.get_schema_builder()
861+
.register(bakery::Entity)
862+
.register(cake::Entity)
863+
.apply(db)?;
864+
865+
info!("delete_<field> on a never-set nullable belongs_to is a no-op, not an error");
866+
cake::ActiveModel::builder()
867+
.set_name("Plain")
868+
.set_price(Decimal::from(5))
869+
.set_gluten_free(true)
870+
.set_serial(Uuid::nil())
871+
.delete_bakery()
872+
.save(db)?;
873+
874+
let reloaded = cake::Entity::find().one(db)?.unwrap();
875+
assert!(reloaded.bakery_id.is_none());
876+
877+
ctx.delete();
878+
879+
Ok(())
880+
}
881+
882+
#[sea_orm_macros::test]
883+
fn test_belongs_to_duplicate_target() -> Result<(), DbErr> {
884+
use common::blogger::*;
885+
886+
let ctx = TestContext::new("test_belongs_to_duplicate_target");
887+
let db = &ctx.db;
888+
889+
db.get_schema_builder()
890+
.register(user::Entity)
891+
.register(user_follower::Entity)
892+
.apply(db)?;
893+
894+
// `user_follower` has two belongs_to fields β€” `user` and `follower` β€” both
895+
// targeting `user::Entity`. Nested writes on such duplicate-target relations
896+
// used to be silently dropped; each now writes its own FK, disambiguated by
897+
// relation (`follower` via its `relation_enum`, `user` via the default).
898+
let alice = user::ActiveModel::builder()
899+
.set_name("Alice")
900+
.set_email("alice@sea-ql.org")
901+
.save(db)?;
902+
let bob = user::ActiveModel::builder()
903+
.set_name("Bob")
904+
.set_email("bob@sea-ql.org")
905+
.save(db)?;
906+
907+
info!("link the two users through the disambiguated nested belongs_to");
908+
let follow = user_follower::ActiveModelEx {
909+
user: ActiveHasOne::set(alice),
910+
follower: ActiveHasOne::set(bob),
911+
..Default::default()
912+
}
913+
.insert(db)?;
914+
915+
// Each belongs_to wrote its own FK (previously a silent no-op).
916+
assert_eq!(follow.user_id, 1);
917+
assert_eq!(follow.follower_id, 2);
918+
919+
let row = user_follower::Entity::find().one(db)?.expect("row");
920+
assert_eq!(row.user_id, 1);
921+
assert_eq!(row.follower_id, 2);
922+
923+
ctx.delete();
924+
925+
Ok(())
926+
}

β€Žsrc/entity/active_model.rsβ€Ž

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,22 @@ pub trait ActiveModelTrait: Clone + Debug {
790790
clear_key_on_active_model(&rel_def.from_col, self)
791791
}
792792

793+
#[doc(hidden)]
794+
/// Clear a specific belongs_to relation, keyed by relation (to disambiguate
795+
/// multiple relations to the same entity), if it is optional; return true.
796+
fn clear_parent_key_for(
797+
&mut self,
798+
rel: <Self::Entity as EntityTrait>::Relation,
799+
) -> Result<bool, DbErr> {
800+
let rel_def = rel.def();
801+
802+
if rel_def.is_owner {
803+
return Err(DbErr::Type(format!("Relation {rel:?} is not belongs_to")));
804+
}
805+
806+
clear_key_on_active_model(&rel_def.from_col, self)
807+
}
808+
793809
#[doc(hidden)]
794810
fn clear_parent_key_for_self_rev(
795811
&mut self,

β€Žsrc/query/util.rsβ€Ž

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,13 @@ where
126126
if !column.def().is_null() {
127127
return Ok(false);
128128
}
129-
model.set(
130-
column,
131-
match model.get(column).into_value() {
132-
Some(value) => value.as_null(),
133-
None => {
134-
return Err(DbErr::AttrNotSet(format!(
135-
"{}.{}",
136-
<ActiveModel::Entity as Default>::default().as_str(),
137-
col_name
138-
)));
139-
}
140-
},
141-
);
129+
// Null out the key column so the detach is persisted. If it was never set
130+
// (a freshly-built ActiveModel) there is nothing to clear β€” it will insert as
131+
// NULL / stay absent on update β€” so treat it as already cleared rather than
132+
// erroring.
133+
if let Some(value) = model.get(column).into_value() {
134+
model.set(column, value.as_null());
135+
}
142136
}
143137

144138
Ok(true)

0 commit comments

Comments
Β (0)