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);
}
}














