W aplikacji którą piszemy musimy zapisywać dane wprowadzone przez użytkownika na formularzu do bazy. Dane można podzielić na ogólne statyczne (adres, opis, itp.) i szczegółowe dynamiczne (lista monitorowanych parametrów i ich wyniki z możliwością dodawania/usuwania/modyfikowania każdego z parametrów wraz z wynikami ze wszystkim dostępnych list).
To co biznes interesuje to jedynie dane ogólne zaś dane dynamiczne mogą ulegać notorycznym zmianom, ich śledzenie jest zbędne. Dlatego też by nie męczyć się z mapowaniami wykorzystałem opcje Cascade.All sądząc, że to też będzie usuwało sieroty.
Jak się okazało to ja byłem sierotą :) Wina leżała w mojej ślepocie (będzie o tym chyba kilka postów), więc dość szybko to naprawiłem ustawiając opcje Cascade.AllDeleteOrphan. Tym razem przy zapisie danych dostałem następujący wyjątek (nazwy klas i przestrzeń nazw zmienione):
A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance: PersistenceDomain.FluentMappings.Person.NickNames
Wyjątek mówi wszystko w tym wypadku – referencja kolekcji w obiekcie Person uległa zmianie, więc NH nie może sobie poradzić, nie wie jakie dane powinny zostać usunięte z bazy.
Okazało się iż wina leży zarówno po stronie AutoMappera jak i MVC.
MVC dlatego, że podczas bindowania danych do modelu tworzy ona nową instancję listy – czyli jeżeli gdzieś była jakakolwiek referencja to została ona utracona. Tego się raczej inaczej nie da zrobić :)
AutoMapper zaś – jak się okazało – nie działał poprawnie ze względu na konfigurację (nie błąd ale pominięcie jednej z opcji dostępnych). Prosty test zobrazowujący konkretny przypadek:
// method name split into 2 lines [Fact] public void Sprint_004_Bug_002_Exception_on_saving_Person_to_db_ AllDeleteOrphan_no_longer_referenced_by_the_owning_entity() { Init(); InitModelToEntityMap(); // instead of mapping entity to model, we are doing what MVC is // we need to create new model and "bind it" to data from "form" // which in this case is our entity PersonModel model = new PersonModel(); model.FirstName = _person.FirstName; model.LastName = _person.LastName; model.NickNames = new List<NickNameModel>(); foreach(var nickName in _person.NickNames) { model.NickNames.Add(new NickNameModel { Nick = nickName.Nick, Place = nickName.Place }); } // in this case we did not do any changes, we just saving same "form" // once again. As we need to save changes and not "new person" we need to // get person from the db and than we can do mapping. Additionaly in // real scenario Versioned concurrency was used. int count = 0; using(var session = SessionFactory.OpenSession()) { Person person = session.Get<Person>(_personId); person = Mapper.Map(model, person); session.Flush(); // if ref is changed this will throw exception about all-delete-orphan Assert.DoesNotThrow(() => session.SaveOrUpdate(person)); session.Flush(); count = session.QueryOver<NickName>().RowCount(); } // additional check, if Cascade.All is set not Cascade.AllDeleteOrphan // this will cause that instead of 2 nicknames in DB we will have // 4 which 2 will be orphan Assert.Equal(model.NickNames.Count, count); } private Guid _personId; private Person _person; private void Init() { Person person = new Person(); person.FirstName = "Jan"; person.LastName = "Kowalski"; person.NickNames.Add(new NickName { Nick = "Kowal", Place = "Karkonosze" }); person.NickNames.Add(new NickName { Nick = "Widla", Place = "Obow wedkarski" }); using(var session = SessionFactory.OpenSession()) { _personId = (Guid)session.Save(person); session.Flush(); _person = session.Get<Person>(_personId); } } private void InitModelToEntityMap() { Mapper.CreateMap<NickNameModel, NickName>() .ForMember(dest => dest.Id, source => source.Ignore()); Mapper.CreateMap<PersonModel, Person>() .ForMember(dest => dest.Id, source => source.Ignore()); }
Na szczęście AutoMapper przychodzi na ratunek z opcją UseDestinationValue() podczas określania mapowania. Oznacza ona tyle, że AutoMapper widząc ją nie stworzy nam nowej kolekcji lub nowego obiektu ale wykorzysta istniejący obiekt, czyli zachowa referencje. W naszym konkretnym przypadku, naprawienie buga polegało na dodaniu jednej linijki w mapowaniu AutoMappera:
private void InitModelToEntityMap() { Mapper.CreateMap<NickNameModel, NickName>() .ForMember(dest => dest.Id, source => source.Ignore()); Mapper.CreateMap<PersonModel, Person>() .ForMember(dest => dest.Id, source => source.Ignore()) .ForMember(dest => dest.NickNames, source => source.UseDestinationValue()); }
Model i mapowanie użyte w przykładzie:
public class Person { public virtual Guid Id { get; set; } public virtual string FirstName { get; set; } public virtual string LastName { get; set; } public virtual ICollection<NickName> NickNames { get; set; } public Person() { NickNames = new List<NickName>(); } } public class NickName { public virtual Guid Id { get; set; } public virtual string Nick { get; set; } public virtual string Place { get; set; } } public class PersonMap : ClassMap<Person> { public PersonMap() { Id(x => x.Id) .GeneratedBy.GuidComb(); Map(x => x.FirstName); Map(x => x.LastName); HasMany(x => x.NickNames) .Fetch.Join() .Cascade.AllDeleteOrphan(); } } public class NickNameMap : ClassMap<NickName> { public NickNameMap() { Id(x => x.Id) .GeneratedBy.GuidComb(); Map(x => x.Nick); Map(x => x.Place); } }