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