From b15f64768961ac5d2dfd8f9c9c907dcae1b4f04d Mon Sep 17 00:00:00 2001 From: Chris Tsang Date: Sat, 4 Jul 2026 00:39:53 +0100 Subject: [PATCH] Non-nullable belongs_to cannot be detached (compile-time); nullable belongs_to Delete now detaches Introduce `ActiveBelongsToNotNull` (variants: NotSet, Set) as the active-side type for a non-nullable `belongs_to`. It has no `Delete` variant, so attempting to detach a relation whose self-FK cannot be nulled is now a compile error rather than a silent no-op or a raw SQL constraint error at runtime. The derive macro inspects each `belongs_to` field's self-FK column: - non-nullable FK -> field typed `ActiveBelongsToNotNull` (no delete_/set_option builders) - nullable FK -> field typed `ActiveHasOne`, and `Delete` now detaches by setting the self-FK to NULL (previously a no-op) `from` is snake_cased before lookup so both a Column name (`BakeryId`) and a field name (`user_id`) resolve to the FK field. Composite or unresolvable `from` values fall back to `ActiveHasOne` with no detach, keeping them compiling. Adds `From`/`From` conversions for the active types and a `test_belongs_to_nullable_detach` integration test. --- sea-orm-macros/src/derives/active_model_ex.rs | 127 ++++++++++++++---- sea-orm-sync/src/entity/active_model_ex.rs | 116 ++++++++++++++++ sea-orm-sync/src/entity/compound/has_many.rs | 10 ++ sea-orm-sync/src/entity/compound/has_one.rs | 29 +++- sea-orm-sync/src/entity/prelude.rs | 2 +- sea-orm-sync/tests/active_model_ex_tests.rs | 55 ++++++-- src/entity/active_model_ex.rs | 116 ++++++++++++++++ src/entity/compound/has_many.rs | 10 ++ src/entity/compound/has_one.rs | 29 +++- src/entity/prelude.rs | 2 +- tests/active_model_ex_tests.rs | 64 +++++++-- 11 files changed, 516 insertions(+), 44 deletions(-) diff --git a/sea-orm-macros/src/derives/active_model_ex.rs b/sea-orm-macros/src/derives/active_model_ex.rs index e84099f71..99d680cc5 100644 --- a/sea-orm-macros/src/derives/active_model_ex.rs +++ b/sea-orm-macros/src/derives/active_model_ex.rs @@ -1,6 +1,7 @@ use super::active_model::DeriveActiveModel; use super::attributes::compound_attr; use super::util::{extract_compound_entity, field_not_ignored_compound, is_compound_field}; +use heck::ToSnakeCase; use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; use std::collections::HashMap; @@ -25,6 +26,8 @@ pub fn expand_derive_active_model_ex( let mut has_many_self_fields = Vec::new(); let mut has_many_via_fields = Vec::new(); let mut has_many_via_self_fields = Vec::new(); + // belongs_to field name -> (self-FK is non-nullable, FK column ident) + let mut belongs_to_meta: HashMap)> = HashMap::new(); let (async_, await_) = async_await(); @@ -44,20 +47,26 @@ pub fn expand_derive_active_model_ex( })?; let mut entity_count = HashMap::new(); + // Column name -> whether the scalar column is nullable (`Option<_>`). Used to decide + // whether a `belongs_to`'s self-FK can be detached (only nullable ones can). + let mut column_nullable: HashMap = HashMap::new(); if let Data::Struct(item_struct) = &data { if let Fields::Named(fields) = &item_struct.fields { for field in fields.named.iter() { - if field.ident.is_some() && field_not_ignored_compound(field) { + if let Some(ident) = &field.ident { let field_type = &field.ty; let field_type: String = quote! { #field_type } .to_string() // e.g.: "Option < String >" .split_whitespace() .collect(); // Remove all whitespace - if is_compound_field(&field_type) { + if field_not_ignored_compound(field) && is_compound_field(&field_type) { let entity_path = extract_compound_entity(&field_type); *entity_count.entry(entity_path.to_owned()).or_insert(0) += 1; + } else { + column_nullable + .insert(ident.to_string(), field_type.starts_with("Option<")); } } } @@ -103,6 +112,25 @@ pub fn expand_derive_active_model_ex( // can only Related to another Entity once if compound_attrs.belongs_to.is_some() { belongs_to_fields.push(ident.clone()); + // `from` may be a Column name (`BakeryId`) or a field + // name (`user_id`); the FK field is always its snake_case. + let from_col = compound_attrs + .from + .as_ref() + .map(|f| f.value().to_snake_case()); + let not_null = from_col + .as_ref() + .and_then(|c| column_nullable.get(c).copied()) + .map(|nullable| !nullable) + .unwrap_or(false); + // Only detach single-column FKs we can resolve to a + // real scalar column (skip composite / unknown `from`). + let fk_col = from_col + .as_deref() + .filter(|c| column_nullable.contains_key(*c)) + .and_then(|c| syn::parse_str::(c).ok()); + belongs_to_meta + .insert(ident.to_string(), (not_null, fk_col)); } else if compound_attrs.has_one.is_some() { has_one_fields.push(ident.clone()); } else if compound_attrs.has_many.is_some() @@ -142,7 +170,17 @@ pub fn expand_derive_active_model_ex( } if field_type.starts_with("HasOne<") { - syn::parse_str(&format!("ActiveHasOne < {entity_path} >"))? + let not_null = belongs_to_meta + .get(&ident.to_string()) + .map(|(nn, _)| *nn) + .unwrap_or(false); + if not_null { + syn::parse_str(&format!( + "ActiveBelongsToNotNull < {entity_path} >" + ))? + } else { + syn::parse_str(&format!("ActiveHasOne < {entity_path} >"))? + } } else { syn::parse_str(&format!("ActiveHasMany < {entity_path} >"))? } @@ -171,9 +209,10 @@ pub fn expand_derive_active_model_ex( &has_many_self_fields, &has_many_via_fields, &has_many_via_self_fields, + &belongs_to_meta, ); - let active_model_setters = expand_active_model_setters(data)?; + let active_model_setters = expand_active_model_setters(data, &belongs_to_meta)?; let mut is_changed_expr = quote!(false); @@ -259,7 +298,7 @@ pub fn expand_derive_active_model_ex( fn from(m: ModelEx) -> Self { Self { #(#scalar_fields: sea_orm::ActiveValue::Unchanged(m.#scalar_fields),)* - #(#compound_fields: m.#compound_fields.into_active_model(),)* + #(#compound_fields: m.#compound_fields.into(),)* } } } @@ -325,6 +364,7 @@ pub fn expand_derive_active_model_ex( }) } +#[allow(clippy::too_many_arguments)] fn expand_active_model_action( belongs_to: &[Ident], belongs_to_self: &[(Ident, LitStr)], @@ -333,6 +373,7 @@ fn expand_active_model_action( has_many_self: &[(Ident, LitStr)], has_many_via: &[(Ident, String)], has_many_via_self: &[(Ident, String, bool)], + belongs_to_meta: &HashMap)>, ) -> TokenStream { let mut belongs_to_action = TokenStream::new(); let mut belongs_to_after_action = TokenStream::new(); @@ -354,7 +395,27 @@ fn expand_active_model_action( }; for field in belongs_to { + let (not_null, fk_col) = belongs_to_meta + .get(&field.to_string()) + .cloned() + .unwrap_or((false, None)); + // Non-nullable belongs_to fields are `ActiveBelongsToNotNull` (no `Delete`); nullable + // ones are `ActiveHasOne` and support detach (`Delete` -> null the self FK). + let ctor = if not_null { + quote!(ActiveBelongsToNotNull) + } else { + quote!(ActiveHasOne) + }; + let detach = match (not_null, &fk_col) { + (false, Some(fk)) => quote! { + if self.#field.is_delete() { + self.#fk = sea_orm::ActiveValue::Set(None); + } + }, + _ => quote!(), + }; belongs_to_action.extend(quote! { + #detach let #field = if let Some(model) = self.#field.take() { if model.is_update() { // has primary key @@ -378,7 +439,7 @@ fn expand_active_model_action( belongs_to_after_action.extend(quote! { if let Some(#field) = #field { - model.#field = ActiveHasOne::set(#field); + model.#field = #ctor::set(#field); } }); } @@ -676,7 +737,10 @@ fn expand_active_model_action( where C: sea_orm::TransactionTrait, { - use sea_orm::{ActiveHasOne, ActiveHasMany, IntoActiveModel, TransactionSession}; + use sea_orm::{ + ActiveBelongsToNotNull, ActiveHasMany, ActiveHasOne, IntoActiveModel, + TransactionSession, + }; let txn = db.begin()#await_?; let db = &txn; let mut deleted = sea_orm::DeleteResult::empty(); @@ -714,7 +778,10 @@ fn expand_active_model_action( } } -fn expand_active_model_setters(data: &Data) -> syn::Result { +fn expand_active_model_setters( + data: &Data, + belongs_to_meta: &HashMap)>, +) -> syn::Result { let mut setters = TokenStream::new(); if let Data::Struct(item_struct) = &data { @@ -757,8 +824,6 @@ fn expand_active_model_setters(data: &Data) -> syn::Result { if field_type_str.starts_with("HasOne<") { let setter = format_ident!("set_{}", ident); - let setter_option = format_ident!("set_{}_option", ident); - let deleter = format_ident!("delete_{}", ident); setters.extend(quote! { #[doc = " Generated by sea-orm-macros"] @@ -766,22 +831,36 @@ fn expand_active_model_setters(data: &Data) -> syn::Result { self.#ident.replace(v.into()); self } + }); - #[doc = " Generated by sea-orm-macros"] - pub fn #setter_option(mut self, v: Option>) -> Self { - self.#ident = match v { - Some(v) => sea_orm::ActiveHasOne::set(v.into()), - None => sea_orm::ActiveHasOne::Delete, - }; - self - } + // Detach builders (`set__option`, `delete_`) are only + // valid where the relation can be nulled: has_one, or a nullable + // belongs_to. A non-nullable belongs_to is `ActiveBelongsToNotNull`, + // which has no `Delete`, so we don't emit them. + let not_null = belongs_to_meta + .get(&ident.to_string()) + .map(|(nn, _)| *nn) + .unwrap_or(false); + if !not_null { + let setter_option = format_ident!("set_{}_option", ident); + let deleter = format_ident!("delete_{}", ident); + setters.extend(quote! { + #[doc = " Generated by sea-orm-macros"] + pub fn #setter_option(mut self, v: Option>) -> Self { + self.#ident = match v { + Some(v) => sea_orm::ActiveHasOne::set(v.into()), + None => sea_orm::ActiveHasOne::Delete, + }; + self + } - #[doc = " Generated by sea-orm-macros"] - pub fn #deleter(mut self) -> Self { - self.#ident = sea_orm::ActiveHasOne::Delete; - self - } - }); + #[doc = " Generated by sea-orm-macros"] + pub fn #deleter(mut self) -> Self { + self.#ident = sea_orm::ActiveHasOne::Delete; + self + } + }); + } } else { let setter = format_ident!( "add_{}", diff --git a/sea-orm-sync/src/entity/active_model_ex.rs b/sea-orm-sync/src/entity/active_model_ex.rs index c5a7b0baa..9672e81fa 100644 --- a/sea-orm-sync/src/entity/active_model_ex.rs +++ b/sea-orm-sync/src/entity/active_model_ex.rs @@ -208,6 +208,122 @@ where { } +/// State carried by a **non-nullable** `belongs_to` field on an +/// [`ActiveModelEx`](crate::EntityTrait::ActiveModelEx). Like [`ActiveHasOne`], but +/// with no `Delete` variant: a non-nullable foreign key can't be set to `NULL`, so the +/// relation can't be detached — that state is simply not representable, making the +/// "can't delete a non-nullable belongs_to" rule a compile-time guarantee. +/// +/// Unstable: nested-`ActiveModel` relation mutation is exempt from semver — the +/// semantics may change in a minor (2.x) release. +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub enum ActiveBelongsToNotNull { + /// Field is absent; the related model is left as-is on save. + #[default] + NotSet, + /// Assign this related ActiveModel on save. + Set(Box), +} + +impl ActiveBelongsToNotNull +where + E: EntityTrait, +{ + /// Construct a `Set` + pub fn set>(model: AM) -> Self { + Self::Set(Box::new(model.into())) + } + + /// Replace the inner model + pub fn replace>(&mut self, model: AM) { + *self = Self::Set(Box::new(model.into())); + } + + /// Take ownership of this model, leaving `NotSet` in place + pub fn take(&mut self) -> Option { + match std::mem::take(self) { + Self::Set(model) => Some(*model), + _ => None, + } + } + + /// Get a reference, if set + pub fn as_ref(&self) -> Option<&E::ActiveModelEx> { + match self { + Self::Set(model) => Some(model), + _ => None, + } + } + + /// Get a mutable reference, if set + #[allow(clippy::should_implement_trait)] + pub fn as_mut(&mut self) -> Option<&mut E::ActiveModelEx> { + match self { + Self::Set(model) => Some(model), + _ => None, + } + } + + /// Return true if there is a model + pub fn is_set(&self) -> bool { + matches!(self, Self::Set(_)) + } + + /// Return true if self is NotSet + pub fn is_not_set(&self) -> bool { + matches!(self, Self::NotSet) + } + + /// Return true if the containing model is set and changed + pub fn is_changed(&self) -> bool { + match self { + Self::Set(model) => model.is_changed(), + _ => false, + } + } + + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Set(model) => Some(*model), + Self::NotSet => None, + } + } + + /// Convert this back to a `ModelEx` container + pub fn try_into_model(self) -> Result, DbErr> + where + E::ActiveModelEx: TryIntoModel, + { + Ok(match self { + Self::Set(model) => HasOne::Loaded(Box::new((*model).try_into_model()?)), + Self::NotSet => HasOne::Unloaded, + }) + } +} + +impl PartialEq for ActiveBelongsToNotNull +where + E: EntityTrait, + E::ActiveModelEx: PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::NotSet, Self::NotSet) => true, + (Self::Set(a), Self::Set(b)) => a == b, + _ => false, + } + } +} + +impl Eq for ActiveBelongsToNotNull +where + E: EntityTrait, + E::ActiveModelEx: Eq, +{ +} + impl ActiveHasMany where E: EntityTrait, diff --git a/sea-orm-sync/src/entity/compound/has_many.rs b/sea-orm-sync/src/entity/compound/has_many.rs index 8e963206b..77d73093a 100644 --- a/sea-orm-sync/src/entity/compound/has_many.rs +++ b/sea-orm-sync/src/entity/compound/has_many.rs @@ -84,6 +84,16 @@ where } } +impl From> for ActiveHasMany +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + fn from(value: HasMany) -> Self { + value.into_active_model() + } +} + impl From> for Option> { fn from(value: HasMany) -> Self { match value { diff --git a/sea-orm-sync/src/entity/compound/has_one.rs b/sea-orm-sync/src/entity/compound/has_one.rs index 0d729f77a..00d93e60d 100644 --- a/sea-orm-sync/src/entity/compound/has_one.rs +++ b/sea-orm-sync/src/entity/compound/has_one.rs @@ -1,4 +1,4 @@ -use crate::{ActiveHasOne, EntityTrait}; +use crate::{ActiveBelongsToNotNull, ActiveHasOne, EntityTrait}; use std::hash::{Hash, Hasher}; #[derive(Debug, Default, Clone)] @@ -94,6 +94,33 @@ where } } +impl From> for ActiveHasOne +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + fn from(value: HasOne) -> Self { + value.into_active_model() + } +} + +impl From> for ActiveBelongsToNotNull +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + fn from(value: HasOne) -> Self { + match value { + HasOne::Loaded(_) => { + let model = value.unwrap(); + let active_model: E::ActiveModelEx = model.into(); + ActiveBelongsToNotNull::Set(active_model.into()) + } + HasOne::Unloaded | HasOne::NotFound => ActiveBelongsToNotNull::NotSet, + } + } +} + impl PartialEq for HasOne where E: EntityTrait, diff --git a/sea-orm-sync/src/entity/prelude.rs b/sea-orm-sync/src/entity/prelude.rs index e5eeab9e7..d6cec78a3 100644 --- a/sea-orm-sync/src/entity/prelude.rs +++ b/sea-orm-sync/src/entity/prelude.rs @@ -16,7 +16,7 @@ pub use crate::{ DeriveRelatedEntity, DeriveRelation, DeriveValueType, FromJsonQueryResult, }; -pub use super::active_model_ex::{ActiveHasMany, ActiveHasOne}; +pub use super::active_model_ex::{ActiveBelongsToNotNull, ActiveHasMany, ActiveHasOne}; pub use super::compound::{HasMany, HasOne}; #[cfg(not(feature = "sync"))] diff --git a/sea-orm-sync/tests/active_model_ex_tests.rs b/sea-orm-sync/tests/active_model_ex_tests.rs index 4f49cce65..088d58c06 100644 --- a/sea-orm-sync/tests/active_model_ex_tests.rs +++ b/sea-orm-sync/tests/active_model_ex_tests.rs @@ -44,7 +44,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(1), user_id: Unchanged(1), title: Unchanged("post 1".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { id: Unchanged(1), name: Unchanged("Alice".into()), email: Unchanged("@1".into()), @@ -68,7 +68,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { if false { post::ActiveModelEx { title: Set("post 2".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { name: Set("Bob".into()), email: Set("@2".into()), ..Default::default() @@ -83,7 +83,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), user_id: Unchanged(2), title: Unchanged("post 2".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { id: Unchanged(2), name: Unchanged("Bob".into()), email: Unchanged("@2".into()), @@ -122,7 +122,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(1), picture: Unchanged("Sam.jpg".into()), user_id: Unchanged(3), - user: ActiveHasOne::NotSet, + user: ActiveBelongsToNotNull::NotSet, }), ..Default::default() } @@ -147,7 +147,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), picture: Unchanged("Alan.jpg".into()), user_id: Unchanged(4), - user: ActiveHasOne::NotSet, + user: ActiveBelongsToNotNull::NotSet, }), posts: ActiveHasMany::Append(vec![ post::ActiveModelEx { @@ -275,7 +275,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: NotSet, user_id: NotSet, title: Set("post 7".into()), - author: ActiveHasOne::set(user.clone().into_active_model()), + author: ActiveBelongsToNotNull::set(user.clone().into_active_model()), comments: ActiveHasMany::NotSet, attachments: ActiveHasMany::NotSet, tags: ActiveHasMany::Append(vec![ @@ -304,7 +304,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(7), user_id: Unchanged(4), title: Unchanged("post 7".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { id: Unchanged(4), name: Unchanged("Alan".into()), email: Unchanged("@4".into()), @@ -312,7 +312,7 @@ fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), picture: Unchanged("Alan2.jpg".into()), user_id: Unchanged(4), - user: ActiveHasOne::NotSet, + user: ActiveBelongsToNotNull::NotSet, }), posts: ActiveHasMany::Append(vec![]), followers: ActiveHasMany::NotSet, @@ -775,3 +775,42 @@ fn test_has_one_replace_and_delete() -> Result<(), DbErr> { Ok(()) } + +#[sea_orm_macros::test] +fn test_belongs_to_nullable_detach() -> Result<(), DbErr> { + use common::bakery_dense::{bakery, cake}; + + let ctx = TestContext::new("test_belongs_to_nullable_detach"); + let db = &ctx.db; + + db.get_schema_builder() + .register(bakery::Entity) + .register(cake::Entity) + .apply(db)?; + + info!("create a cake linked to a bakery (nullable belongs_to)"); + let cake = cake::ActiveModel::builder() + .set_name("Cheesecake") + .set_price(Decimal::from(10)) + .set_gluten_free(false) + .set_serial(Uuid::nil()) + .set_bakery( + bakery::ActiveModel::builder() + .set_name("SeaSide") + .set_profit_margin(10.0), + ) + .save(db)?; + + assert!(cake::Entity::find().one(db)?.unwrap().bakery_id.is_some()); + + info!("detach the nullable belongs_to via delete_: nulls the FK, keeps both rows"); + cake.delete_bakery().save(db)?; + + let reloaded = cake::Entity::find().one(db)?.unwrap(); + assert!(reloaded.bakery_id.is_none()); + assert_eq!(bakery::Entity::find().all(db)?.len(), 1); + + ctx.delete(); + + Ok(()) +} diff --git a/src/entity/active_model_ex.rs b/src/entity/active_model_ex.rs index c5a7b0baa..9672e81fa 100644 --- a/src/entity/active_model_ex.rs +++ b/src/entity/active_model_ex.rs @@ -208,6 +208,122 @@ where { } +/// State carried by a **non-nullable** `belongs_to` field on an +/// [`ActiveModelEx`](crate::EntityTrait::ActiveModelEx). Like [`ActiveHasOne`], but +/// with no `Delete` variant: a non-nullable foreign key can't be set to `NULL`, so the +/// relation can't be detached — that state is simply not representable, making the +/// "can't delete a non-nullable belongs_to" rule a compile-time guarantee. +/// +/// Unstable: nested-`ActiveModel` relation mutation is exempt from semver — the +/// semantics may change in a minor (2.x) release. +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub enum ActiveBelongsToNotNull { + /// Field is absent; the related model is left as-is on save. + #[default] + NotSet, + /// Assign this related ActiveModel on save. + Set(Box), +} + +impl ActiveBelongsToNotNull +where + E: EntityTrait, +{ + /// Construct a `Set` + pub fn set>(model: AM) -> Self { + Self::Set(Box::new(model.into())) + } + + /// Replace the inner model + pub fn replace>(&mut self, model: AM) { + *self = Self::Set(Box::new(model.into())); + } + + /// Take ownership of this model, leaving `NotSet` in place + pub fn take(&mut self) -> Option { + match std::mem::take(self) { + Self::Set(model) => Some(*model), + _ => None, + } + } + + /// Get a reference, if set + pub fn as_ref(&self) -> Option<&E::ActiveModelEx> { + match self { + Self::Set(model) => Some(model), + _ => None, + } + } + + /// Get a mutable reference, if set + #[allow(clippy::should_implement_trait)] + pub fn as_mut(&mut self) -> Option<&mut E::ActiveModelEx> { + match self { + Self::Set(model) => Some(model), + _ => None, + } + } + + /// Return true if there is a model + pub fn is_set(&self) -> bool { + matches!(self, Self::Set(_)) + } + + /// Return true if self is NotSet + pub fn is_not_set(&self) -> bool { + matches!(self, Self::NotSet) + } + + /// Return true if the containing model is set and changed + pub fn is_changed(&self) -> bool { + match self { + Self::Set(model) => model.is_changed(), + _ => false, + } + } + + /// Convert into an `Option` + pub fn into_option(self) -> Option { + match self { + Self::Set(model) => Some(*model), + Self::NotSet => None, + } + } + + /// Convert this back to a `ModelEx` container + pub fn try_into_model(self) -> Result, DbErr> + where + E::ActiveModelEx: TryIntoModel, + { + Ok(match self { + Self::Set(model) => HasOne::Loaded(Box::new((*model).try_into_model()?)), + Self::NotSet => HasOne::Unloaded, + }) + } +} + +impl PartialEq for ActiveBelongsToNotNull +where + E: EntityTrait, + E::ActiveModelEx: PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::NotSet, Self::NotSet) => true, + (Self::Set(a), Self::Set(b)) => a == b, + _ => false, + } + } +} + +impl Eq for ActiveBelongsToNotNull +where + E: EntityTrait, + E::ActiveModelEx: Eq, +{ +} + impl ActiveHasMany where E: EntityTrait, diff --git a/src/entity/compound/has_many.rs b/src/entity/compound/has_many.rs index 8e963206b..77d73093a 100644 --- a/src/entity/compound/has_many.rs +++ b/src/entity/compound/has_many.rs @@ -84,6 +84,16 @@ where } } +impl From> for ActiveHasMany +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + fn from(value: HasMany) -> Self { + value.into_active_model() + } +} + impl From> for Option> { fn from(value: HasMany) -> Self { match value { diff --git a/src/entity/compound/has_one.rs b/src/entity/compound/has_one.rs index 0d729f77a..00d93e60d 100644 --- a/src/entity/compound/has_one.rs +++ b/src/entity/compound/has_one.rs @@ -1,4 +1,4 @@ -use crate::{ActiveHasOne, EntityTrait}; +use crate::{ActiveBelongsToNotNull, ActiveHasOne, EntityTrait}; use std::hash::{Hash, Hasher}; #[derive(Debug, Default, Clone)] @@ -94,6 +94,33 @@ where } } +impl From> for ActiveHasOne +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + fn from(value: HasOne) -> Self { + value.into_active_model() + } +} + +impl From> for ActiveBelongsToNotNull +where + E: EntityTrait, + E::ActiveModelEx: From, +{ + fn from(value: HasOne) -> Self { + match value { + HasOne::Loaded(_) => { + let model = value.unwrap(); + let active_model: E::ActiveModelEx = model.into(); + ActiveBelongsToNotNull::Set(active_model.into()) + } + HasOne::Unloaded | HasOne::NotFound => ActiveBelongsToNotNull::NotSet, + } + } +} + impl PartialEq for HasOne where E: EntityTrait, diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs index e5eeab9e7..d6cec78a3 100644 --- a/src/entity/prelude.rs +++ b/src/entity/prelude.rs @@ -16,7 +16,7 @@ pub use crate::{ DeriveRelatedEntity, DeriveRelation, DeriveValueType, FromJsonQueryResult, }; -pub use super::active_model_ex::{ActiveHasMany, ActiveHasOne}; +pub use super::active_model_ex::{ActiveBelongsToNotNull, ActiveHasMany, ActiveHasOne}; pub use super::compound::{HasMany, HasOne}; #[cfg(not(feature = "sync"))] diff --git a/tests/active_model_ex_tests.rs b/tests/active_model_ex_tests.rs index d307bac7c..7a3f7b3f2 100644 --- a/tests/active_model_ex_tests.rs +++ b/tests/active_model_ex_tests.rs @@ -47,7 +47,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(1), user_id: Unchanged(1), title: Unchanged("post 1".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { id: Unchanged(1), name: Unchanged("Alice".into()), email: Unchanged("@1".into()), @@ -72,7 +72,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { if false { post::ActiveModelEx { title: Set("post 2".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { name: Set("Bob".into()), email: Set("@2".into()), ..Default::default() @@ -87,7 +87,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), user_id: Unchanged(2), title: Unchanged("post 2".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { id: Unchanged(2), name: Unchanged("Bob".into()), email: Unchanged("@2".into()), @@ -127,7 +127,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(1), picture: Unchanged("Sam.jpg".into()), user_id: Unchanged(3), - user: ActiveHasOne::NotSet, + user: ActiveBelongsToNotNull::NotSet, }), ..Default::default() } @@ -153,7 +153,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), picture: Unchanged("Alan.jpg".into()), user_id: Unchanged(4), - user: ActiveHasOne::NotSet, + user: ActiveBelongsToNotNull::NotSet, }), posts: ActiveHasMany::Append(vec![ post::ActiveModelEx { @@ -284,7 +284,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: NotSet, user_id: NotSet, title: Set("post 7".into()), - author: ActiveHasOne::set(user.clone().into_active_model()), + author: ActiveBelongsToNotNull::set(user.clone().into_active_model()), comments: ActiveHasMany::NotSet, attachments: ActiveHasMany::NotSet, tags: ActiveHasMany::Append(vec![ @@ -313,7 +313,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(7), user_id: Unchanged(4), title: Unchanged("post 7".into()), - author: ActiveHasOne::set(user::ActiveModelEx { + author: ActiveBelongsToNotNull::set(user::ActiveModelEx { id: Unchanged(4), name: Unchanged("Alan".into()), email: Unchanged("@4".into()), @@ -321,7 +321,7 @@ async fn test_active_model_ex_blog() -> Result<(), DbErr> { id: Unchanged(2), picture: Unchanged("Alan2.jpg".into()), user_id: Unchanged(4), - user: ActiveHasOne::NotSet, + user: ActiveBelongsToNotNull::NotSet, }), posts: ActiveHasMany::Append(vec![]), followers: ActiveHasMany::NotSet, @@ -830,3 +830,51 @@ async fn test_has_one_replace_and_delete() -> Result<(), DbErr> { Ok(()) } + +#[sea_orm_macros::test] +async fn test_belongs_to_nullable_detach() -> Result<(), DbErr> { + use common::bakery_dense::{bakery, cake}; + + let ctx = TestContext::new("test_belongs_to_nullable_detach").await; + let db = &ctx.db; + + db.get_schema_builder() + .register(bakery::Entity) + .register(cake::Entity) + .apply(db) + .await?; + + info!("create a cake linked to a bakery (nullable belongs_to)"); + let cake = cake::ActiveModel::builder() + .set_name("Cheesecake") + .set_price(Decimal::from(10)) + .set_gluten_free(false) + .set_serial(Uuid::nil()) + .set_bakery( + bakery::ActiveModel::builder() + .set_name("SeaSide") + .set_profit_margin(10.0), + ) + .save(db) + .await?; + + assert!( + cake::Entity::find() + .one(db) + .await? + .unwrap() + .bakery_id + .is_some() + ); + + info!("detach the nullable belongs_to via delete_: nulls the FK, keeps both rows"); + cake.delete_bakery().save(db).await?; + + let reloaded = cake::Entity::find().one(db).await?.unwrap(); + assert!(reloaded.bakery_id.is_none()); + assert_eq!(bakery::Entity::find().all(db).await?.len(), 1); + + ctx.delete().await; + + Ok(()) +}