Ostatnio wspominałem o pewnym problemie, który spotka każdego chętnego, by skorzystać z generycznego repozytorium używając jednocześnie Entity Framework. Problemem tym jest edycja encji, które zawierają w sobie relacje many-to-many. Sam problem jest banalny gdy mamy oddzielne repozytoria lub korzystamy bezpośrednio z DbContext, ale jeśli chcemy trzymać się generyczności, to musimy nieco zmienić naszą metodę Update.
Problem
Gdy edytujemy nasz obiekt i dokonujemy zapisu za pośrednictwem standardowej metody update, wszystkie zmienione wartości zostają zapisane w bazie. Niestety nie dotyczy to kolekcji, która reprezentuje relacje wiele-wiele. Zacznijmy od encji, która sprawiała problemy.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public abstract class OrderItem : IEntity { public int ID { get; set; } public decimal Price { get; set; } public string Name { get; set; } public string Description { get; set; } public virtual bool IsAvailable { get; set; } } public class Product : OrderItem { public virtual ICollection<Ingredient> Ingredients { get; set; } } |
Jeśli użyjemy standardowej metody Update, to będziemy musieli tłumaczyć kucharzowi, dlaczego nie może zmienić zdania i nagle zacząć dokładać do hamburgerów cebuli.
Przyczyna
Przyczyna problemu jest stosunkowo prosta. Po przekazaniu obiektu do widoku, zmodyfikowaniu go i przesłaniu do metody Update repozytorium jest on śledzony przez DbContext, ale elementy jego kolekcji już nie. W związku z tym nasz Product będzie zawierał nasz Ingredient w kolekcji, ale Ingredient nie będzie miał w swojej kolekcji obiektu Product, który modyfikujemy. W przypadku korzystania ze zwykłego repozytorium lub bezpośrednie z DbContextu wystarczyłoby użycie metody Include. Niestety moje rozwiązanie nieco komplikuje sytuację.
Rozwiązanie
Pa jakimś czasie na google trafiłem w końcu na rozwiązanie problemu, które po pewnych modyfikacjach udało mi się dostosować do swoich potrzeb. Przeciążona metoda Update, która służy do obsługi obiektów zawierających relacje many-to-many wygląda następująco.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public void Update(T entity, IEnumerable<object> updatedSet, string propertyName) { Type type = updatedSet.GetType().GetGenericArguments()[0]; T previous = dbSet.Include(propertyName).First(e => e.ID == entity.ID); var values = ListHelper.CreateList(type); foreach (var entry in updatedSet .Select(obj => (int)obj .GetType() .GetProperty("ID") .GetValue(obj, null)) .Select(value => context.Set(type).Find(value))) { values.Add(entry); } context.Entry(previous).Collection(propertyName).CurrentValue = values; context.Entry(previous).CurrentValues.SetValues(entity); } |
Metoda przyjmuje 3 parametry. Pierwszym z nich jest referencja do obiektu, na którym chcemy wykonać Update. Drugi z nich to kolekcja, którą ma zawierać, trzecia to nazwa właściwości, która jest modyfikowaną kolekcją w encji, którą aktualizujemy.
Aby wszystko działało z każdym możliwym typem kolekcji, drugi parametr metody musi pobierać kolekcję obiektów typu object. W związku z tym, w kolejnej linii musimy ustalić jakie właściwie obiekty trafiły do metody, by móc swobodnie na nich operować.
Zaraz potem w tym miejscu T previous = dbSet.Include(propertyName).First(e => e.ID == entity.ID); tworzymy referencję do poprzedniej (sprzed edycji)wersji obiektu który edytujemy i używając metody Include, o której wspomniałem wcześniej, wczytujemy zawartość jego kolekcji. Po co nam potrzebny stary obiekt, który zaraz i tak zmienimy? W zasadzie po nic, ale jeśli tego nie zrobimy i usuniemy coś z jego kolekcji, to zmiany te nie zostaną zapisane, bo bez tego DbContext nie będzie śledził encji, które były zawarte w poprzedniej wersji obiektu.
Zaraz potem musimy stworzyć listę, która będzie odpowiadała typowi kolekcji, którą zawiera nasz obiekt. Najprostsze rozwiązanie jakie znalazłem to linia var values = ListHelper.CreateList(type);, która korzysta z niewielkiej metody zwracającej listę.
1 2 3 4 5 6 7 8 |
public static class ListHelper { public static IList CreateList(Type type) { var genericList = typeof(List<>).MakeGenericType(type); return (IList)Activator.CreateInstance(genericList); } } |
Największe problemy miałem z tym fragmentem kodu, który niekoniecznie chciał ze mną współpracować. Kluczem jest oczywiście zrozumienie tego, co dzieję się w całym wyrażeniu i dlaczego.
1 2 3 4 5 6 7 8 9 |
foreach (var entry in updatedSet .Select(obj => (int)obj .GetType() .GetProperty("ID") .GetValue(obj, null)) .Select(value => context.Set(type).Find(value))) { values.Add(entry); } |
Otóż dla każdego obiektu z kolekcji updatedSet (drugi parametr naszej metody) znajdujemy odpowiadające mu encje w bazie danych, wyciągamy je z niej i dodajemy do utworzonej chwilę wcześniej listy values. Jej zadaniem jest przechowanie ich do następnego kroku, który kończy całą tą metodę.
1 2 |
context.Entry(previous).Collection(propertyName).CurrentValue = values; context.Entry(previous).CurrentValues.SetValues(entity); |
Pierwsza linia zamienia całą kolekcję obiektu na nową, która jest przechowywana w values. Druga zamienia wszystkie inne właściwości naszej encji na te, które zawiera dostarczona przez nas zmodyfikowana encja.
Wnioski
Powyższy problem nie byłby nawet w połowie tak skomplikowany, gdybyśmy korzystali bezpośrednio z DbContextu i pominęli całą zabawę z generycznym repozytorium. Eksperyment ten ma swoją cenę i dobitnie pokazał mi jakim bezsensem naprawdę jest używanie wzorca repozytorium w połączeniu z EF. Odcinamy się w ten sposób od mnóstwa narzędzi, które daje nam ten ORM i jeśli już podjęliśmy decyzję o korzystaniu z niego w swoim projekcie, to dorzucając do niego repozytorium pozbywamy się dostępu do ogromnej ilości funkcjonalności.
Dlatego też bardzo poważnie zastanawiam się nad wyrzuceniem całego repozytorium z projektu. Jestem jeszcze na stosunkowo wczesnym etapie prac, więc nie wymagałoby to aż tak dużo pracy.