Entity Framework – aktualizacja odłączonych obiektów

Urlop! Chciałbym, żeby właśnie takie było wytłumaczenie długiej przerwy od ostatniego wpisu. Niestety, jak wiecie, nadmiar pracy jest tą najczęstszą przyczyną w takich wypadkach. Niemniej czytacie kolejne wynurzenia z pola bitwy co oznacza, że wyrwałem się spod jarzma obowiązków – radujmy się!:)

Zajmowałem się do tej pory tematami ogólnie organizującymi kształt projektu oraz tymi związanymi z odczytywaniem i przekazywaniem danych między warstwami aplikacji. Dziś chciałbym poruszyć temat zapisu danych do bazy. Konkretnie natomiast problemu na jaki napotkamy chcąc dodać lub zaktualizować nieco bardziej skomplikowany obiekt za pomocą Entity Framework.

Wiem – sam się o to prosiłem. Wiem – trzeba było wybrać NHibernate i nie cierpieć. Cóż, każdy wybór miałby zady i walety – w tym przypadku objawił mi się jednak zad Entity Frameworka:)

Jak zwykle do demonstracji wykorzystamy nasz nieśmiertelny obiekt Movie:

public class Movie
{
    public int ID { get; set; }
    public string Title { get; set; }
    public int ProductionYear { get; set; }

    public virtual ICollection<Genre> Genres { get; set; }
    public virtual ICollection<MovieRating> Ratings { get; set; }
}

Jak widzimy ma on proste właściwości: Title, ProductionYear oraz relacje z kolekcjami obiektów Genre i MovieRating. Podczas dodawania i edycji nowego obiektu wszystkie jego właściwości musimy więc poprawnie zapisać w bazie danych. W jednym z poprzednich wpisów zaprezentowałem klasę MovieCreateOrUpdateDto, która z widoku jest przekazywana do metod Create lub Update serwisu MovieService.

Pierwsza rzecz to wymapowanie obiektu MovieCreateOrUpdateDto na obiekt Movie. Spróbujmy zatem użyć do tego AutoMappera:

var config = new MapperConfiguration(
    cfg =>
        {
            cfg.CreateMap<MovieCreateOrUpdateDto, Movie>()
            .ForMember(src => src.Genres, m => m.MapFrom(src => src.Genres.Where(g => g.Checked)));
            cfg.CreateMap<GenreSelectionDto, Genre>();
        }
);
var mapper = config.CreateMapper();

Wygląda prosto: do kolekcji Genres obiektu Movie mapujemy tylko zaznaczone gatunki, dodatkowo mapujemy obiekty GenreSelectionDto na Genre. Co potem? Ano w przypadku aktualizacji istniejącego obiektu:

Movie movie = appContext.Movies.Where(m => m.ID == updateModel.ID).FirstOrDefault();
mapper.Map<MovieCreateOrUpdateDto, Movie>(updateModel, movie);
appContext.Movies.Attach(movie);
appContext.SaveChanges();

Efekt będzie, delikatnie mówiąc, mało satysfakcjonujący:) Proste właściwości obiektu movie zostaną zaktualizowane, lecz kolekcja Genres zostanie potraktowana jako zbiór nowych obiektów i w bazie pojawią nam się nowe rekordy w tabeli Genre (i powiązania w tabeli MovieGenre). Entity Framework nie wie bowiem, że odnosimy się do istniejących obiektów (a mógłby np. z uwagi na niezerowy identyfikator).

Ok, spróbujmy zatem podczas mapowania określić, że w kolekcji są obiekty już istniejące w bazie. Konfiguracja mappera wyglądać może wtedy tak:

var config = new MapperConfiguration(
    cfg =>
        {
            cfg.CreateMap<MovieCreateOrUpdateDto, Movie>()
            .ForMember(src => src.Genres, m => m.MapFrom(src => src.Genres.Where(g => g.Checked)));
            cfg.CreateMap<GenreSelectionDto, Genre>()
            .ConstructUsing( (GenreSelectionDto dto) =>
            {
                return appContext.Genres.First(g => g.ID == dto.ID);
            });
        }
);

Automapper będzie do mapowanie obiektów GenreSelectionDto na Genre używał wczytanych w bazy gatunków filmowych. Po zmapowaniu obiektu MovieCreateOrUpdateDto do wczytanego z bazy Movie elementy kolekcji będą obiektami znanymi Entity Framework i aktualizacja danych się powiedzie. Jest to jednak rozwiązanie, które do konfiguracji AutoMappera wkłada kontekst bazy danych – nie wydaje się to poprawne.

Zauważcie, że w przykładach wczytuję obiekt Movie z bazy danych i aktualizuję na podstawie MovieCreateOrUpdate. A gdybym po prostu utworzył nowy obiekt Movie, wypełnił go i chciał zapisać? Pojawiłyby się kolejne problemy, musiałbym ręcznie określić stan obiektu jako zmieniony, miałbym problem z mapowaniem z uwagi na pole ID (chronione przez Entity Framework). To właśnie jedna z ułomności Entity Framework: praca z odłączonymi obiektami.

Rozwiązaniem, które wybrałem jest użycie biblioteki GraphDiff, która z tą ułomnością pozwala sobie poradzić. Możemy wtedy napisać taki kawałek kodu:

var config = new MapperConfiguration(
    cfg =>
        {
            cfg.CreateMap<MovieCreateOrUpdateDto, Movie>()
            .ForMember(src => src.Genres, m => m.MapFrom(src => src.Genres.Where(g => g.Checked)));
            cfg.CreateMap<GenreSelectionDto, Genre>();
        }
);
var mapper = config.CreateMapper();
Movie movie = new Movie();
mapper.Map<MovieCreateOrUpdateDto, Movie>(updateModel, movie);
appContext.UpdateGraph(movie, m => m.AssociatedCollection(p => p.Genres));
appContext.SaveChanges();

Tworzymy obiekt Movie, mapujemy do niego dane z MovieCreateOrUpdateDto, a następnie GraphDiff-ową metodą UpdateGraph() powiązać go z obiektem istniejącym już w bazie i zaktualizować jego właściwości. GraphDiff sprawdzi, które elementy kolekcji dodać, które usunąć, a które zmodyfikować. Szkoda jedynie, że tą funkcjonalność musimy dosztukować, a nie jest dostępna „po wyjęciu z pudełka”.

 

Marcin

Dodaj komentarz