diff --git a/sea-orm-macros/src/derives/active_model_ex.rs b/sea-orm-macros/src/derives/active_model_ex.rs index 5644498547..a5eaa6bac9 100644 --- a/sea-orm-macros/src/derives/active_model_ex.rs +++ b/sea-orm-macros/src/derives/active_model_ex.rs @@ -533,10 +533,10 @@ impl<'a> ActiveModelSetter<'a> { }) } CompoundKind::HasMany => { - let setter = format_ident!( - "add_{}", - pluralizer::pluralize(&field_ident.to_string(), 1, false) - ); + let plural_name = pluralizer::pluralize(&field_ident.to_string(), 1, false); + let setter = format_ident!("add_{}", plural_name); + let remove = format_ident!("delete_{}", plural_name); + let remove_existing = format_ident!("delete_existing_{}", plural_name); Ok(quote! { #[doc = " Generated by sea-orm-macros"] @@ -544,6 +544,18 @@ impl<'a> ActiveModelSetter<'a> { self.#field_ident.push(v.into()); self } + + #[doc = " Generated by sea-orm-macros"] + pub fn #remove(mut self, v: impl Into<#active_model_type>) -> Self { + self.#field_ident.push_delete(v.into()); + self + } + + #[doc = " Generated by sea-orm-macros"] + pub fn #remove_existing(mut self, v: impl std::borrow::Borrow) -> Self { + self.#field_ident.push_delete_existing(v.borrow()); + self + } }) } } @@ -1227,7 +1239,6 @@ impl HasManySelfField<'_> { let relation_variant = quote!(Relation::#relation_variant); let delete_associated_model = quote! { - let mut item = item.into_active_model(); if item.clear_parent_key_for_self_rev(#relation_variant)? { item.update(db)#await_?; } else { @@ -1237,16 +1248,27 @@ impl HasManySelfField<'_> { }; let has_many_before_action = quote! { - let #field_binding = self.#ident.take(); + let mut #field_binding = self.#ident.take(); }; let has_many_action = quote! { - if #field_binding.is_replace() { - for item in model.find_belongs_to_self(#relation_variant, db.get_database_backend())?.all(db)#await_? { - if !#field_binding.find(&item) { + match #field_binding { + ActiveHasMany::Replace(_) => { + for item in model.find_belongs_to_self(#relation_variant, db.get_database_backend())?.all(db)#await_? { + if !#field_binding.find(&item) { + let mut item = item.into_active_model(); + #delete_associated_model + } + } + }, + ActiveHasMany::Mutate(ref mut mutations) => { + let deletes = std::mem::take(mutations.delete_as_mut_vec()); + for item in deletes { + let mut item: self::ActiveModel = item.into(); #delete_associated_model } - } + }, + _ => () } model.#ident = #field_binding.empty_holder(); for mut #field_binding in #field_binding.into_vec() { @@ -1262,6 +1284,7 @@ impl HasManySelfField<'_> { let has_many_delete = quote! { for item in self.find_belongs_to_self(#relation_variant, db.get_database_backend())?.all(db)#await_? { + let mut item = item.into_active_model(); #delete_associated_model } }; @@ -1352,7 +1375,7 @@ impl HasManyField<'_> { let ident = self.ident; let field_binding = clone_with_mixed_site_span(ident); quote! { - let #field_binding = self.#ident.take(); + let mut #field_binding = self.#ident.take(); } } @@ -1366,21 +1389,39 @@ impl HasManyField<'_> { let ident = self.ident; let field_binding = clone_with_mixed_site_span(ident); let related_entity = self.entity; - let delete_associated_model = quote! { - let mut item = item.into_active_model(); - if item.clear_parent_key::()? { - item.update(db)#await_?; + let delete_associated_model_gen = |into_ex: bool| { + let merge_tokens = if into_ex { + quote! { item.into_ex().delete(db)#await_? } } else { - deleted.merge(item.into_ex().delete(db)#await_?); // deep delete + quote! { item.delete(db)#await_? } + }; + quote! { + if item.clear_parent_key::()? { + item.update(db)#await_?; + } else { + deleted.merge(#merge_tokens); // deep delete + } } }; + let delete_associated_model_into_ex = delete_associated_model_gen(true); + let delete_associated_model = delete_associated_model_gen(false); quote! { - if #field_binding.is_replace() { - for item in model.find_related(#related_entity).all(db)#await_? { - if !#field_binding.find(&item) { + match #field_binding { + ActiveHasMany::Replace(_) => { + for item in model.find_related(#related_entity).all(db)#await_? { + if !#field_binding.find(&item) { + let mut item = item.into_active_model(); + #delete_associated_model_into_ex + } + } + }, + ActiveHasMany::Mutate(ref mut mutations) => { + let deletes = std::mem::take(mutations.delete_as_mut_vec()); + for mut item in deletes { #delete_associated_model } - } + }, + _ => () } model.#ident = #field_binding.empty_holder(); for mut #field_binding in #field_binding.into_vec() { diff --git a/sea-orm-sync/src/entity/active_model_ex.rs b/sea-orm-sync/src/entity/active_model_ex.rs index d16d038334..165384a9e8 100644 --- a/sea-orm-sync/src/entity/active_model_ex.rs +++ b/sea-orm-sync/src/entity/active_model_ex.rs @@ -59,6 +59,104 @@ pub enum ActiveHasMany { /// Persist these related models alongside any existing children; never /// deletes. Append(Vec), + + /// Persists the related model alongside any existing children with the + /// ability to remove models. + Mutate(Mutation), +} + +/// Container that holds models to save and delete +#[derive_where::derive_where(Debug, Clone, PartialEq, Default, Eq; E::ActiveModelEx)] +pub struct Mutation { + save: Vec, + delete: Vec, +} + +/// Container that holds models that need to be persisted and deleted +/// NOTE: The ordering of models are **not** guaranteed after the first +/// operation is preformed. +impl Mutation { + /// Create an empty container + pub fn new() -> Self { + Self { + save: vec![], + delete: vec![], + } + } + + /// Create a container with models to delete and save + pub fn from_save_and_delete( + save: Vec, + delete: Vec, + ) -> Self { + Self { save, delete } + } + + /// Create a container with models to save + pub fn from_save(save: Vec) -> Self { + Self { + save, + delete: vec![], + } + } + + /// Create a container with models to delete + pub fn from_delete(save: Vec) -> Self { + Self { + save, + delete: vec![], + } + } + + /// Get a slice to models that will be saved + pub fn save_as_slice(&self) -> &[E::ActiveModelEx] { + self.save.as_slice() + } + + /// Get a mutable vector to models that will be saved + pub fn save_as_mut_vec(&mut self) -> &mut Vec { + &mut self.save + } + + /// Get a mutable vector to models that will be deleted + pub fn delete_as_mut_vec(&mut self) -> &mut Vec { + &mut self.delete + } + + /// Get a slice to models that will be deleted + pub fn delete_as_slice(&self) -> &[E::ActiveModelEx] { + self.delete.as_slice() + } + + /// Consume [self] and produce a single [Vec] of all + /// models in the container + pub fn into_vec(mut self) -> Vec { + self.save.extend(self.delete); + self.save + } + + /// Consume [self] and produce a single [Vec] of models + /// to be saved + pub fn into_save_vec(self) -> Vec { + self.save + } + + /// Iterate over all models in the container + pub fn iter(&self) -> impl Iterator { + self.save.iter().chain(self.delete.iter()) + } +} + +impl IntoIterator for Mutation { + type Item = E::ActiveModelEx; + type IntoIter = std::iter::Chain< + std::vec::IntoIter, + std::vec::IntoIter, + >; + + fn into_iter(self) -> Self::IntoIter { + self.save.into_iter().chain(self.delete) + } } /// Which save operation an [`ActiveModel`](crate::ActiveModelTrait) is about @@ -280,17 +378,23 @@ where } /// Borrow models as slice + /// NOTE: For [Self::Mutate] this will only return models that + /// will be saved. pub fn as_slice(&self) -> &[E::ActiveModelEx] { match self { Self::Replace(models) | Self::Append(models) => models, + Self::Mutate(mutations) => &mutations.save, Self::NotSet => &[], } } /// Get a mutable vec. If self is `NotSet`, convert to append. + /// NOTE: For [Self::Mutate] this will only return models that + /// will be saved. pub fn as_mut_vec(&mut self) -> &mut Vec { match self { Self::Replace(models) | Self::Append(models) => models, + Self::Mutate(mutations) => mutations.save_as_mut_vec(), Self::NotSet => { *self = Self::Append(vec![]); self.as_mut_vec() @@ -299,9 +403,12 @@ where } /// Consume self as vector + /// NOTE: For [Self::Mutate] this will only return models that + /// will be saved. pub fn into_vec(self) -> Vec { match self { Self::Replace(models) | Self::Append(models) => models, + Self::Mutate(mutations) => mutations.into_save_vec(), Self::NotSet => vec![], } } @@ -311,15 +418,33 @@ where match self { Self::Replace(_) => Self::Replace(vec![]), Self::Append(_) => Self::Append(vec![]), + Self::Mutate(_) => Self::Mutate(Mutation::new()), Self::NotSet => Self::NotSet, } } /// Push an item to self + /// + /// If [Self::Mutate] and the given model matches a loaded model + /// marked for deletion that model will not longer be deleted. pub fn push>(&mut self, model: AM) -> &mut Self { let model = model.into(); match self { Self::Replace(models) | Self::Append(models) => models.push(model), + Self::Mutate(mutations) => { + let pk = model.get_primary_key_value(); + if pk.is_some() { + let idx = mutations + .delete + .iter() + .position(|x| x.get_primary_key_value() == pk); + + if let Some(idx) = idx { + mutations.delete.swap_remove(idx); + } + } + mutations.save.push(model) + } Self::NotSet => { *self = Self::Append(vec![model]); } @@ -328,6 +453,86 @@ where self } + /// Add an ActiveModelEx that will be deleted on save. No changes occur + /// if the primary key of the given model is not set. + /// NOTE: Will promote [Self::Replace], [Self::Append], & [Self::NotSet] to [Self::Mutate] + pub fn push_delete>(&mut self, model: AM) -> &mut Self { + let model = model.into(); + + let pk = model.get_primary_key_value(); + + if pk.is_none() { + return self; + } + + match self { + Self::Mutate(mutations) => { + let idx = mutations + .save + .iter() + .position(|x| x.get_primary_key_value() == pk); + + if let Some(idx) = idx { + mutations.save.swap_remove(idx); + } + mutations.delete.push(model) + } + Self::Replace(models) | Self::Append(models) => { + let mut models = std::mem::take(models); + let idx = models.iter().position(|x| x.get_primary_key_value() == pk); + + if let Some(idx) = idx { + models.swap_remove(idx); + } + *self = Self::Mutate(Mutation::from_save_and_delete(models, vec![model])); + } + Self::NotSet => { + *self = Self::Mutate(Mutation::from_delete(vec![model])); + } + } + + self + } + + /// Mark a model loaded into the active model as deleted, if an active model + /// with the given primary key is not loaded no action will be taken. + /// NOTE: Will promote [Self::Replace], [Self::Append], & [Self::NotSet] to [Self::Mutate] even + /// if no active model is found + pub fn push_delete_existing(&mut self, pk: &sea_query::ValueTuple) -> &mut Self { + match self { + Self::Mutate(mutations) => { + let some_pk = Some(pk); + let idx = mutations + .save + .iter() + .position(|x| x.get_primary_key_value().as_ref() == some_pk); + + if let Some(idx) = idx { + mutations.delete.push(mutations.save.swap_remove(idx)); + } + } + Self::Replace(models) | Self::Append(models) => { + let mut models = std::mem::take(models); + let some_pk = Some(pk); + let idx = models + .iter() + .position(|x| x.get_primary_key_value().as_ref() == some_pk); + + if let Some(idx) = idx { + let model = models.swap_remove(idx); + *self = Self::Mutate(Mutation::from_save_and_delete(models, vec![model])); + } else { + *self = Self::Mutate(Mutation::from_save(models)); + } + } + Self::NotSet => { + *self = Self::Mutate(Mutation::new()); + } + } + + self + } + /// Push an item to self, but convert Replace to Append pub fn append>(&mut self, model: AM) -> &mut Self { self.convert_to_append().push(model) @@ -342,12 +547,34 @@ where self } - /// Convert self to Append, if set + /// Convert [self] to [Self::Append], if set + /// NOTE: If converting [self::Mutation] only models + /// that will be saved/inserted will be retained pub fn convert_to_append(&mut self) -> &mut Self { match self.take() { Self::Replace(models) | Self::Append(models) => { *self = Self::Append(models); } + Self::Mutate(mutations) => { + *self = Self::Append(mutations.save); + } + Self::NotSet => { + *self = Self::NotSet; + } + } + + self + } + + /// Convert [self] to [Self::Mutate], if set + pub fn convert_to_mutation(&mut self) -> &mut Self { + match self.take() { + Self::Replace(models) | Self::Append(models) => { + *self = Self::Mutate(Mutation::from_save(models)); + } + Self::Mutate(mutations) => { + *self = Self::Mutate(mutations); + } Self::NotSet => { *self = Self::NotSet; } @@ -371,11 +598,20 @@ where matches!(self, Self::Append(_)) } + /// If self is `Mutate` + pub fn is_mutate(&self) -> bool { + matches!(self, Self::Mutate(_)) + } + /// Return true if self is `Replace` or any containing model is changed pub fn is_changed(&self) -> bool { match self { Self::Replace(_) => true, Self::Append(models) => models.iter().any(|model| model.is_changed()), + Self::Mutate(mutations) => { + !mutations.delete.is_empty() + || mutations.save.iter().any(|model| model.is_changed()) + } Self::NotSet => false, } } @@ -407,6 +643,12 @@ where .map(|t| t.try_into_model()) .collect::, DbErr>>()?, ), + Self::Mutate(mutations) => HasMany::Loaded( + mutations + .into_iter() + .map(|t| t.try_into_model()) + .collect::, DbErr>>()?, + ), Self::NotSet => HasMany::Unloaded, }) } @@ -417,6 +659,7 @@ impl From> for Option> { match value { ActiveHasMany::NotSet => None, ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => Some(models), + ActiveHasMany::Mutate(mutations) => Some(mutations.into_vec()), } } } @@ -430,6 +673,7 @@ impl Index for ActiveHasMany { panic!("index out of bounds: the ActiveHasMany is NotSet (index: {index})") } ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => models.index(index), + ActiveHasMany::Mutate(mutations) => mutations.save.index(index), } } } @@ -443,6 +687,7 @@ impl IndexMut for ActiveHasMany { ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => { models.index_mut(index) } + ActiveHasMany::Mutate(mutations) => mutations.save.index_mut(index), } } } @@ -454,6 +699,7 @@ impl IntoIterator for ActiveHasMany { fn into_iter(self) -> Self::IntoIter { match self { ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => models.into_iter(), + ActiveHasMany::Mutate(mutations) => mutations.save.into_iter(), ActiveHasMany::NotSet => Vec::new().into_iter(), } } diff --git a/sea-orm-sync/tests/active_model_ex_tests.rs b/sea-orm-sync/tests/active_model_ex_tests.rs index d724b95533..6a8592f6b3 100644 --- a/sea-orm-sync/tests/active_model_ex_tests.rs +++ b/sea-orm-sync/tests/active_model_ex_tests.rs @@ -828,6 +828,175 @@ fn test_has_one_replace_and_delete() -> Result<(), DbErr> { Ok(()) } +/// Compare the state of the given active model +macro_rules! blogger_user_check_db_state { + ($db:ident, $am:ident) => { + let db_user = user::Entity::load() + .filter_by_id(*$am.id.as_ref()) + .with(profile::Entity) + .with(post::Entity) + .with((post::Entity, tag::Entity)) + .one($db)? + .expect("Expected user but got None"); + + let db_am = db_user.into_active_model(); + + let am_str = format!("{:#?}", $am); + let db_am_str = format!("{:#?}", db_am); + if am_str != db_am_str { + panic!( + "======= Active Model =======\n{}\n======= DB Active Model =======\n{}", + am_str, db_am_str + ); + } + }; +} + +#[sea_orm_macros::test] +fn test_has_many_mutate() -> Result<(), DbErr> { + use common::blogger::*; + + let ctx = TestContext::new("test_has_many_mutate"); + let db = &ctx.db; + + db.get_schema_builder() + .register(user::Entity) + .register(user_follower::Entity) + .register(profile::Entity) + .register(post::Entity) + .register(post_tag::Entity) + .register(tag::Entity) + .register(attachment::Entity) + .register(comment::Entity) + .apply(db)?; + + info!("#3061: Creating user with a post that has a comment"); + let mut user = user::ActiveModel::builder() + .set_name("Rick") + .set_email("rick@sea-ql.org") + .set_profile(profile::ActiveModel::builder().set_picture("first.jpg")) + .add_post( + post::ActiveModel::builder() + .set_title("Amazing Post") + .add_tag(tag::ActiveModel::builder().set_tag("Amazing")) + .add_tag(tag::ActiveModel::builder().set_tag("Best Post")), + ) + .add_post( + post::ActiveModel::builder() + .set_title("Cool Post") + .add_tag(tag::ActiveModel::builder().set_tag("Cool")), + ) + .save(db)?; + + assert!(user.posts.as_slice().len() == 2); + + blogger_user_check_db_state!(db, user); + + let amazing_post_id = *user.posts[0].id.clone().as_ref(); + let cool_post_id = *user.posts[1].id.clone().as_ref(); + let cool_post_pk = user.posts[1].get_primary_key_value().unwrap(); + let cool_post_tags = user.posts[1].tags.clone(); + + info!("#3061: Deleting 'Amazing' post"); + user = user.delete_post(post::ActiveModel::builder().set_id(amazing_post_id)); + user = user.save(db)?; + + // Convert posts back to append so the state is consistent with the database model + user.posts.convert_to_append(); + + blogger_user_check_db_state!(db, user); + assert!(user.posts.as_slice().len() == 1); + + info!("#3061: Deleting and adding 'Cool' post"); + user = user + .delete_existing_post(&cool_post_pk) + .add_post( + post::ActiveModel::builder() + .set_id(cool_post_id) + .set_title("Really Cool Post"), + ) + .save(db)?; + + // Convert posts back to append and add tag back so the state is consistent with the database model + // We expect the tags to persist because the saved active model has them as NotSet + user.posts.convert_to_append(); + user.posts[0].tags = cool_post_tags; + + blogger_user_check_db_state!(db, user); + assert!(user.posts.as_slice().len() == 1); + assert!(user.posts.as_slice()[0].title.as_ref() == "Really Cool Post"); + + ctx.delete(); + + Ok(()) +} + +#[sea_orm_macros::test] +fn test_has_many_self_mutate() -> Result<(), DbErr> { + use common::film_store::*; + + let ctx = TestContext::new("test_has_many_self_mutate"); + let db = &ctx.db; + + db.get_schema_builder().register(staff::Entity).apply(db)?; + + let mut staff = staff::ActiveModel::builder() + .set_name("Bob") + .add_manage( + staff::ActiveModel::builder().set_name("Rick").add_manage( + staff::ActiveModel::builder() + .set_name("Alice") + .add_manage(staff::ActiveModel::builder().set_name("Sam")), + ), + ) + .add_manage(staff::ActiveModel::builder().set_name("Tom")) + .save(db)?; + + assert_eq!(staff.manages.as_slice().len(), 2); + + info!("delete rick as staff member managed by bob"); + let rick_id = *staff.manages[0].id.as_ref(); + let rick_pk = staff.manages[0].get_primary_key_value().unwrap(); + staff = staff.delete_existing_manage(&rick_pk).save(db)?; + + assert_eq!(staff.manages.as_slice().len(), 1); + assert_eq!(staff.manages.as_slice()[0].name.as_ref(), "Tom"); + + //TODO: Currently we do no support selecting deeply nested self referential + //relationships. Once we do this code can be simplified by comparing the db model + // to converted to an active model the active model in memory. (https://github.com/SeaQL/sea-orm/issues/2942) + + let db_model = staff::Entity::load() + .with(staff::Relation::ReportsTo) + .with(staff::Relation::Manages) + .filter_by_id(*staff.id.as_ref()) + .one(db)? + .unwrap(); + + assert_eq!(db_model.manages.len(), 1); + assert_eq!(db_model.manages[0].name, "Tom"); + + info!("add rick as staff member managed by bob"); + + staff = staff + .add_manage(staff::ActiveModel::builder().set_id(rick_id)) + .save(db)?; + + let db_model = staff::Entity::load() + .with(staff::Relation::ReportsTo) + .with(staff::Relation::Manages) + .filter_by_id(*staff.id.as_ref()) + .one(db)? + .unwrap(); + + assert_eq!(db_model.manages.len(), 2); + assert!(db_model.manages.iter().any(|x| x.name == "Rick")); + + ctx.delete(); + + Ok(()) +} + #[sea_orm_macros::test] fn test_belongs_to_duplicate_target() -> Result<(), DbErr> { use common::blogger::*; diff --git a/src/entity/active_model_ex.rs b/src/entity/active_model_ex.rs index d16d038334..165384a9e8 100644 --- a/src/entity/active_model_ex.rs +++ b/src/entity/active_model_ex.rs @@ -59,6 +59,104 @@ pub enum ActiveHasMany { /// Persist these related models alongside any existing children; never /// deletes. Append(Vec), + + /// Persists the related model alongside any existing children with the + /// ability to remove models. + Mutate(Mutation), +} + +/// Container that holds models to save and delete +#[derive_where::derive_where(Debug, Clone, PartialEq, Default, Eq; E::ActiveModelEx)] +pub struct Mutation { + save: Vec, + delete: Vec, +} + +/// Container that holds models that need to be persisted and deleted +/// NOTE: The ordering of models are **not** guaranteed after the first +/// operation is preformed. +impl Mutation { + /// Create an empty container + pub fn new() -> Self { + Self { + save: vec![], + delete: vec![], + } + } + + /// Create a container with models to delete and save + pub fn from_save_and_delete( + save: Vec, + delete: Vec, + ) -> Self { + Self { save, delete } + } + + /// Create a container with models to save + pub fn from_save(save: Vec) -> Self { + Self { + save, + delete: vec![], + } + } + + /// Create a container with models to delete + pub fn from_delete(save: Vec) -> Self { + Self { + save, + delete: vec![], + } + } + + /// Get a slice to models that will be saved + pub fn save_as_slice(&self) -> &[E::ActiveModelEx] { + self.save.as_slice() + } + + /// Get a mutable vector to models that will be saved + pub fn save_as_mut_vec(&mut self) -> &mut Vec { + &mut self.save + } + + /// Get a mutable vector to models that will be deleted + pub fn delete_as_mut_vec(&mut self) -> &mut Vec { + &mut self.delete + } + + /// Get a slice to models that will be deleted + pub fn delete_as_slice(&self) -> &[E::ActiveModelEx] { + self.delete.as_slice() + } + + /// Consume [self] and produce a single [Vec] of all + /// models in the container + pub fn into_vec(mut self) -> Vec { + self.save.extend(self.delete); + self.save + } + + /// Consume [self] and produce a single [Vec] of models + /// to be saved + pub fn into_save_vec(self) -> Vec { + self.save + } + + /// Iterate over all models in the container + pub fn iter(&self) -> impl Iterator { + self.save.iter().chain(self.delete.iter()) + } +} + +impl IntoIterator for Mutation { + type Item = E::ActiveModelEx; + type IntoIter = std::iter::Chain< + std::vec::IntoIter, + std::vec::IntoIter, + >; + + fn into_iter(self) -> Self::IntoIter { + self.save.into_iter().chain(self.delete) + } } /// Which save operation an [`ActiveModel`](crate::ActiveModelTrait) is about @@ -280,17 +378,23 @@ where } /// Borrow models as slice + /// NOTE: For [Self::Mutate] this will only return models that + /// will be saved. pub fn as_slice(&self) -> &[E::ActiveModelEx] { match self { Self::Replace(models) | Self::Append(models) => models, + Self::Mutate(mutations) => &mutations.save, Self::NotSet => &[], } } /// Get a mutable vec. If self is `NotSet`, convert to append. + /// NOTE: For [Self::Mutate] this will only return models that + /// will be saved. pub fn as_mut_vec(&mut self) -> &mut Vec { match self { Self::Replace(models) | Self::Append(models) => models, + Self::Mutate(mutations) => mutations.save_as_mut_vec(), Self::NotSet => { *self = Self::Append(vec![]); self.as_mut_vec() @@ -299,9 +403,12 @@ where } /// Consume self as vector + /// NOTE: For [Self::Mutate] this will only return models that + /// will be saved. pub fn into_vec(self) -> Vec { match self { Self::Replace(models) | Self::Append(models) => models, + Self::Mutate(mutations) => mutations.into_save_vec(), Self::NotSet => vec![], } } @@ -311,15 +418,33 @@ where match self { Self::Replace(_) => Self::Replace(vec![]), Self::Append(_) => Self::Append(vec![]), + Self::Mutate(_) => Self::Mutate(Mutation::new()), Self::NotSet => Self::NotSet, } } /// Push an item to self + /// + /// If [Self::Mutate] and the given model matches a loaded model + /// marked for deletion that model will not longer be deleted. pub fn push>(&mut self, model: AM) -> &mut Self { let model = model.into(); match self { Self::Replace(models) | Self::Append(models) => models.push(model), + Self::Mutate(mutations) => { + let pk = model.get_primary_key_value(); + if pk.is_some() { + let idx = mutations + .delete + .iter() + .position(|x| x.get_primary_key_value() == pk); + + if let Some(idx) = idx { + mutations.delete.swap_remove(idx); + } + } + mutations.save.push(model) + } Self::NotSet => { *self = Self::Append(vec![model]); } @@ -328,6 +453,86 @@ where self } + /// Add an ActiveModelEx that will be deleted on save. No changes occur + /// if the primary key of the given model is not set. + /// NOTE: Will promote [Self::Replace], [Self::Append], & [Self::NotSet] to [Self::Mutate] + pub fn push_delete>(&mut self, model: AM) -> &mut Self { + let model = model.into(); + + let pk = model.get_primary_key_value(); + + if pk.is_none() { + return self; + } + + match self { + Self::Mutate(mutations) => { + let idx = mutations + .save + .iter() + .position(|x| x.get_primary_key_value() == pk); + + if let Some(idx) = idx { + mutations.save.swap_remove(idx); + } + mutations.delete.push(model) + } + Self::Replace(models) | Self::Append(models) => { + let mut models = std::mem::take(models); + let idx = models.iter().position(|x| x.get_primary_key_value() == pk); + + if let Some(idx) = idx { + models.swap_remove(idx); + } + *self = Self::Mutate(Mutation::from_save_and_delete(models, vec![model])); + } + Self::NotSet => { + *self = Self::Mutate(Mutation::from_delete(vec![model])); + } + } + + self + } + + /// Mark a model loaded into the active model as deleted, if an active model + /// with the given primary key is not loaded no action will be taken. + /// NOTE: Will promote [Self::Replace], [Self::Append], & [Self::NotSet] to [Self::Mutate] even + /// if no active model is found + pub fn push_delete_existing(&mut self, pk: &sea_query::ValueTuple) -> &mut Self { + match self { + Self::Mutate(mutations) => { + let some_pk = Some(pk); + let idx = mutations + .save + .iter() + .position(|x| x.get_primary_key_value().as_ref() == some_pk); + + if let Some(idx) = idx { + mutations.delete.push(mutations.save.swap_remove(idx)); + } + } + Self::Replace(models) | Self::Append(models) => { + let mut models = std::mem::take(models); + let some_pk = Some(pk); + let idx = models + .iter() + .position(|x| x.get_primary_key_value().as_ref() == some_pk); + + if let Some(idx) = idx { + let model = models.swap_remove(idx); + *self = Self::Mutate(Mutation::from_save_and_delete(models, vec![model])); + } else { + *self = Self::Mutate(Mutation::from_save(models)); + } + } + Self::NotSet => { + *self = Self::Mutate(Mutation::new()); + } + } + + self + } + /// Push an item to self, but convert Replace to Append pub fn append>(&mut self, model: AM) -> &mut Self { self.convert_to_append().push(model) @@ -342,12 +547,34 @@ where self } - /// Convert self to Append, if set + /// Convert [self] to [Self::Append], if set + /// NOTE: If converting [self::Mutation] only models + /// that will be saved/inserted will be retained pub fn convert_to_append(&mut self) -> &mut Self { match self.take() { Self::Replace(models) | Self::Append(models) => { *self = Self::Append(models); } + Self::Mutate(mutations) => { + *self = Self::Append(mutations.save); + } + Self::NotSet => { + *self = Self::NotSet; + } + } + + self + } + + /// Convert [self] to [Self::Mutate], if set + pub fn convert_to_mutation(&mut self) -> &mut Self { + match self.take() { + Self::Replace(models) | Self::Append(models) => { + *self = Self::Mutate(Mutation::from_save(models)); + } + Self::Mutate(mutations) => { + *self = Self::Mutate(mutations); + } Self::NotSet => { *self = Self::NotSet; } @@ -371,11 +598,20 @@ where matches!(self, Self::Append(_)) } + /// If self is `Mutate` + pub fn is_mutate(&self) -> bool { + matches!(self, Self::Mutate(_)) + } + /// Return true if self is `Replace` or any containing model is changed pub fn is_changed(&self) -> bool { match self { Self::Replace(_) => true, Self::Append(models) => models.iter().any(|model| model.is_changed()), + Self::Mutate(mutations) => { + !mutations.delete.is_empty() + || mutations.save.iter().any(|model| model.is_changed()) + } Self::NotSet => false, } } @@ -407,6 +643,12 @@ where .map(|t| t.try_into_model()) .collect::, DbErr>>()?, ), + Self::Mutate(mutations) => HasMany::Loaded( + mutations + .into_iter() + .map(|t| t.try_into_model()) + .collect::, DbErr>>()?, + ), Self::NotSet => HasMany::Unloaded, }) } @@ -417,6 +659,7 @@ impl From> for Option> { match value { ActiveHasMany::NotSet => None, ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => Some(models), + ActiveHasMany::Mutate(mutations) => Some(mutations.into_vec()), } } } @@ -430,6 +673,7 @@ impl Index for ActiveHasMany { panic!("index out of bounds: the ActiveHasMany is NotSet (index: {index})") } ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => models.index(index), + ActiveHasMany::Mutate(mutations) => mutations.save.index(index), } } } @@ -443,6 +687,7 @@ impl IndexMut for ActiveHasMany { ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => { models.index_mut(index) } + ActiveHasMany::Mutate(mutations) => mutations.save.index_mut(index), } } } @@ -454,6 +699,7 @@ impl IntoIterator for ActiveHasMany { fn into_iter(self) -> Self::IntoIter { match self { ActiveHasMany::Replace(models) | ActiveHasMany::Append(models) => models.into_iter(), + ActiveHasMany::Mutate(mutations) => mutations.save.into_iter(), ActiveHasMany::NotSet => Vec::new().into_iter(), } } diff --git a/tests/active_model_ex_tests.rs b/tests/active_model_ex_tests.rs index a726652ebe..23dad83209 100644 --- a/tests/active_model_ex_tests.rs +++ b/tests/active_model_ex_tests.rs @@ -883,6 +883,186 @@ async fn test_has_one_replace_and_delete() -> Result<(), DbErr> { Ok(()) } +/// Compare the state of the given active model +macro_rules! blogger_user_check_db_state { + ($db:ident, $am:ident) => { + let db_user = user::Entity::load() + .filter_by_id(*$am.id.as_ref()) + .with(profile::Entity) + .with(post::Entity) + .with((post::Entity, tag::Entity)) + .one($db) + .await? + .expect("Expected user but got None"); + + let db_am = db_user.into_active_model(); + + let am_str = format!("{:#?}", $am); + let db_am_str = format!("{:#?}", db_am); + if am_str != db_am_str { + panic!( + "======= Active Model =======\n{}\n======= DB Active Model =======\n{}", + am_str, db_am_str + ); + } + }; +} + +#[sea_orm_macros::test] +async fn test_has_many_mutate() -> Result<(), DbErr> { + use common::blogger::*; + + let ctx = TestContext::new("test_has_many_mutate").await; + let db = &ctx.db; + + db.get_schema_builder() + .register(user::Entity) + .register(user_follower::Entity) + .register(profile::Entity) + .register(post::Entity) + .register(post_tag::Entity) + .register(tag::Entity) + .register(attachment::Entity) + .register(comment::Entity) + .apply(db) + .await?; + + info!("#3061: Creating user with a post that has a comment"); + let mut user = user::ActiveModel::builder() + .set_name("Rick") + .set_email("rick@sea-ql.org") + .set_profile(profile::ActiveModel::builder().set_picture("first.jpg")) + .add_post( + post::ActiveModel::builder() + .set_title("Amazing Post") + .add_tag(tag::ActiveModel::builder().set_tag("Amazing")) + .add_tag(tag::ActiveModel::builder().set_tag("Best Post")), + ) + .add_post( + post::ActiveModel::builder() + .set_title("Cool Post") + .add_tag(tag::ActiveModel::builder().set_tag("Cool")), + ) + .save(db) + .await?; + + assert!(user.posts.as_slice().len() == 2); + + blogger_user_check_db_state!(db, user); + + let amazing_post_id = *user.posts[0].id.clone().as_ref(); + let cool_post_id = *user.posts[1].id.clone().as_ref(); + let cool_post_pk = user.posts[1].get_primary_key_value().unwrap(); + let cool_post_tags = user.posts[1].tags.clone(); + + info!("#3061: Deleting 'Amazing' post"); + user = user.delete_post(post::ActiveModel::builder().set_id(amazing_post_id)); + user = user.save(db).await?; + + // Convert posts back to append so the state is consistent with the database model + user.posts.convert_to_append(); + + blogger_user_check_db_state!(db, user); + assert!(user.posts.as_slice().len() == 1); + + info!("#3061: Deleting and adding 'Cool' post"); + user = user + .delete_existing_post(&cool_post_pk) + .add_post( + post::ActiveModel::builder() + .set_id(cool_post_id) + .set_title("Really Cool Post"), + ) + .save(db) + .await?; + + // Convert posts back to append and add tag back so the state is consistent with the database model + // We expect the tags to persist because the saved active model has them as NotSet + user.posts.convert_to_append(); + user.posts[0].tags = cool_post_tags; + + blogger_user_check_db_state!(db, user); + assert!(user.posts.as_slice().len() == 1); + assert!(user.posts.as_slice()[0].title.as_ref() == "Really Cool Post"); + + ctx.delete().await; + + Ok(()) +} + +#[sea_orm_macros::test] +async fn test_has_many_self_mutate() -> Result<(), DbErr> { + use common::film_store::*; + + let ctx = TestContext::new("test_has_many_self_mutate").await; + let db = &ctx.db; + + db.get_schema_builder() + .register(staff::Entity) + .apply(db) + .await?; + + let mut staff = staff::ActiveModel::builder() + .set_name("Bob") + .add_manage( + staff::ActiveModel::builder().set_name("Rick").add_manage( + staff::ActiveModel::builder() + .set_name("Alice") + .add_manage(staff::ActiveModel::builder().set_name("Sam")), + ), + ) + .add_manage(staff::ActiveModel::builder().set_name("Tom")) + .save(db) + .await?; + + assert_eq!(staff.manages.as_slice().len(), 2); + + info!("delete rick as staff member managed by bob"); + let rick_id = *staff.manages[0].id.as_ref(); + let rick_pk = staff.manages[0].get_primary_key_value().unwrap(); + staff = staff.delete_existing_manage(&rick_pk).save(db).await?; + + assert_eq!(staff.manages.as_slice().len(), 1); + assert_eq!(staff.manages.as_slice()[0].name.as_ref(), "Tom"); + + //TODO: Currently we do no support selecting deeply nested self referential + //relationships. Once we do this code can be simplified by comparing the db model + // to converted to an active model the active model in memory. (https://github.com/SeaQL/sea-orm/issues/2942) + + let db_model = staff::Entity::load() + .with(staff::Relation::ReportsTo) + .with(staff::Relation::Manages) + .filter_by_id(*staff.id.as_ref()) + .one(db) + .await? + .unwrap(); + + assert_eq!(db_model.manages.len(), 1); + assert_eq!(db_model.manages[0].name, "Tom"); + + info!("add rick as staff member managed by bob"); + + staff = staff + .add_manage(staff::ActiveModel::builder().set_id(rick_id)) + .save(db) + .await?; + + let db_model = staff::Entity::load() + .with(staff::Relation::ReportsTo) + .with(staff::Relation::Manages) + .filter_by_id(*staff.id.as_ref()) + .one(db) + .await? + .unwrap(); + + assert_eq!(db_model.manages.len(), 2); + assert!(db_model.manages.iter().any(|x| x.name == "Rick")); + + ctx.delete().await; + + Ok(()) +} + #[sea_orm_macros::test] async fn test_belongs_to_duplicate_target() -> Result<(), DbErr> { use common::blogger::*;