Interceptory w Entity Framework

Wprowadzenie

W poprzednim wpisie pokazałem Ci, w jaki sposób można dodać wsparcie dla Temporal Table w Entity Framework. Zaproponowane rozwiązanie nie jest idealne i ma swoje problemy. W dzisiejszym wpisie będę chciał Ci pokazać, jak rozwiązać część problemów z wykorzystaniem interceptorów. Umożliwią one modyfikowanie zapytań, które są wykonywane w serwerze bazy danych. Dzięki temu możemy obchodzić niektóre problemy w pracy z Entity Framework.

Rozbudowanie przykładu

W tym wpisie rozbudujemy przykład z wpisu poprzedniego, dlatego jeśli go nie czytałeś (Temporal Table w Entity Framework), to odsyłam Cię do niego. Wielokrotnie będę nawiązywał właśnie do tego wpisu.

W przykładzie dodamy klasę Order, którą następnie połączymy z klasą BaseProduct relacją wiele do wielu. W prawdziwej aplikacji mielibyśmy dodatkową klasę (np. OrderItem), w której znajdowałyby się dodatkowe informacje. Tutaj chodzi tylko o przykład, bez zbytniego rozbudowania.

Relacja ta pokaże nam kilka problemów z użyciem podejścia Table per Concrete Type, które rozwiążemy za pomocą interceptorów.

Po zmianach model danych w testowej aplikacji wygląda tak:

Klasa Order zawiera właściwość dla numeru zamówienia oraz listę powiązanych produktów. W klasie BaseProduct pojawiła się właściwość dla listy zamówień.

Na podstawie zmian wygenerowałem nową migrację:

Podobnie jak wcześniej, należy trochę zmodyfikować migrację. W przypadku relacji wiele do wielu Entity Framework generuje w bazie dodatkową tabelę (w przykładzie jest to OrderBaseProducts), która zawiera kolumny dla kluczy głównych z obu powiązanych tabel. W związku z tym, że oszukujemy Entity Framework co do klucza głównego dla klasy BaseProduct, to w tym miejscu musimy usunąć kolumny powiązane z ValidFrom oraz ValidTo.

Robimy to w dwóch miejscach: w definicji kolumn dodatkowej tabeli oraz w definicji klucza głównego tej tabeli. W migracji zakomentowany kod zawiera to, co wygenerował Entity Framework.

Do klasy Program dodałem dwie nowe metody testowe. Pierwsza metoda tworzy nowe zamówienia z wszystkimi produktami, które znajdują się w bazie. Natomiast druga usuwa z zamówienia jeden produkt:

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 20 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Problem z dodaniem zamówienia

W tym momencie, gdy uruchomimy aplikację, dostaniemy wyjątek podczas dodawania nowego zamówienia. Problem polega na tym, że Entity Framework próbuje dodać do tabeli łączącej wartości z kolumn ValidFrom oraz ValidTo. Widać to w SQL, który wykonuje się w serwerze bazy:

Poprawny SQL, który chcemy wykonać na bazie wygląda tak:

Potrzebujemy czegoś, co umożliwi nam zmodyfikowanie komendy i usunięcie z niej zbędnych kolumn. Tym czymś będzie interceptor.

Interceptory w Entity Framework

Kilka tygodni temu poruszałem temat interceptorów. Wpis dotyczył używania interceptorów w Autofac, ale tutaj idea jest bardzo podobna. Chodzi o to, żeby przygotować fragment kodu, który wykona się podczas tworzenia zapytania przez Entity Framework. Kod ten będzie usuwał zbędne kolumny, w związku z tym, że klucz główny w klasie BaseProduct jest inny niż w bazie danych.

Entity Framework udostępnia kilka różnych typów interceptorów. Dwa najpopularniejsze to IDbCommandInterceptor oraz IDbCommandTreeInterceptor. Pierwszego możemy użyć przed wykonaniem komendy w bazie danych lub po nim (mamy już gotowego SQL). Drugi natomiast jest wykonywany podczas budowania drzewa komendy jeszcze przed tym, jak provider wygeneruje zapytanie dla danego silnika bazy danych. My skorzystamy z tego drugiego typu.

Interfejs IDbCommandTreeInterceptor definiuje jedną metodę TreeCreated, do której otrzymujemy kontekst, a nim zbudowane drzewo komendy (właściwość Result kontekstu), które możemy zmodyfikować.

Implementacja interceptora

Najlepiej od razu przejść do implementacji interceptora, który rozwiązuje problem z dodaniem zamówienia:

Na początku definiujemy listę nazw właściwości do ignorowania. W naszym przypadku będzie to ValidFrom oraz ValidTo.

Następnie w samej metodzie TreeCreated sprawdzamy przede wszystkim DataSpace. Entity Framework wykorzystuje kilka modeli. Po pierwsze mamy Conceptual Model (CSpace), który określa model klas w aplikacji. Drugim rodzajem modelu jest Storage Model (SSpace) określający model bazy danych. Do tego mamy jeszcze mapowanie jednego modelu na drugi (na ogół zapisane w klasie EntityTypeConfiguration).

Będziemy chcieli modyfikować zapytanie na poziomie Storage Model, ponieważ w tym modelu mamy między innymi tę dodatkową tabelę łączącą. Dlatego na początku metody TreeCreated znajduje się sprawdzenie typu modelu.

W naszym przypadku chcemy usunąć dodatkowe kolumny w operacji Insert oraz Update, dlatego sprawdzamy aktualny typ komendy i gdy się zgadza, wywołujemy odpowiednią metodę pomocniczą.

Drzewa komend są immutable, czyli nie możemy ich zmodyfikować, a jedynie możemy utworzyć nowe drzewa, które nie będą zawierały tych dodatkowych kolumn. Właściwość SetClauses w przypadku obu komend zawiera listę kolumn do dodania lub aktualizacji. Dlatego interceptor filtruje tę listę, usuwa z niej zbędne kolumny, a następnie tworzy nowe drzewo. Do sprawdzenia nazw kolumn wykorzystujemy metodę Contains, ponieważ kolumny bardzo często będą zawierały przedrostek, tak jak w przykładzie: BaseProduct_ValidFrom oraz BaseProduct_ValidTo.

Gdy mamy już gotowy interceptor, należy go jeszcze zarejestrować w Entity Framework. Osobiście robię to w statycznym konstruktorze klasy DataContext (klasa zwiera więcej kodu niż sam listing):

Pobieranie danych z bazy

Po rozwiązaniu jednego problemu pojawia się kolejny. W drugiej testowej metodzie pobieramy jeden produkt z zamówienia i próbujemy go usunąć. W tym przypadku dostajemy wyjątek podczas pobierania produktu powiązanego z zamówieniem. Entity Framework generuje takie zapytanie:

Problematyczny jest warunek instrukcji Inner Join (druga oraz trzecia linijka od końca). Znajduje się tam sprawdzanie tych dodatkowych kolumn z datami.

Chcemy teraz pozbyć się dodatkowych sprawdzeń. Zrobimy to również w interceptorze:

Jest to ten sam interceptor co wcześniej. Listing pokazuje tylko ten kod, który jest istotny w tym konkretnym problemie.

Podobnie jak wcześniej sprawdzamy typ modelu oraz typ komendy. Tym razem obsługujemy typ komendy Query (zapytanie SELECT). Robimy to w trochę inny sposób: korzystamy z ExpressionVisitor, który pozwala przejść po drzewie zapytania, przeanalizować je i ostatecznie zmodyfikować.

ExpressionVisitor umożliwia nam nadpisanie wielu metod, które są wywoływane dla innego typu Expression. W przykładzie nadpisujemy metodę Visit dla DbJoinExpression, ponieważ go chcemy zmienić. DbJoinExpression ma właściwość JoinCondition, która zawiera nasz warunek do zmiany.

Tutaj chciałbym podkreślić bardzo wyraźnie jedną rzecz. Zaproponowana implementacja zmiany warunku jest specyficzna dla tego przypadku i prawdopodobnie nie zadziała z każdym innym przypadkiem, który może się u Ciebie pojawić. Nie chciałem zbytnio komplikować kodu, dlatego musisz sam dopasować go do swoich potrzeb.

Modyfikacja warunku Joina

Na potrzeby analizy oraz zmiany warunku Joina przygotowałem dedykowaną klasę. Logikę wykorzystam później podczas zmiany generowania komendy Delete, więc od razy wydzieliłem ją do klasy.

Warunek Joina jest zbudowany z trzech typów wyrażeń. Pierwszym jest And, który łączy inne wyrażenia. Kolejnym jest wyrażenie Comparison, które porównuje wyrażenia Property.

W przykładzie z trzech (czy później w Delete czterech) wyrażeń połączonych And chcemy wybrać i zwrócić tylko te, który nie są powiązane z tymi dodatkowymi kolumnami. Dlatego przechodzimy rekurencyjnie po drzewie. W przypadku wyrażenia And analizujemy dalej lewą oraz prawą część wyrażenia. W przypadku wyrażenia Comparison sprawdzamy, czy lewe oraz prawe wyrażenia są typu Property i jakie są ich nazwy.

Klasa TemporalTableVisitor zawiera dwie właściwości. Expression zwróci wyrażenie, które nie jest powiązane z kolumnami ValidFrom oraz ValidTo (łączę znalezione warunki z listy _expressions). Natomiast IsTemporalExpression zwróci true, jeśli w warunku było wyrażenie z dodatkowymi kolumnami.

Właściwości te służą w QueryVisitor do zdecydowania, czy zmieniamy JoinCondition oraz na jaką wartość.

Po wykonaniu tego kodu zmienione zapytanie działa, nie powoduje błędów i wygląda tak:

Usuwanie danych

Ostatnim problemem w przykładzie jest usuwanie danych z tabeli łączącej. Zapytanie, które generuje Entity Framework, wygląda tak:

I tutaj mamy bardzo podobny problem jak z Join, który rozwiążemy w ten sam sposób. Kod interceptora wygląda tak (pokazuję tylko nowy kod):

Rozwiązanie problemu jest bardzo podobne. Za pomocą klasy DeleteVisitor przechodzimy po warunku Delete i z niego usuwamy wyrażenia dla kolumn ValidFrom oraz ValidTo. W efekcie otrzymujemy zapytanie:

Przykład

Na githubie (https://github.com/danielplawgo/SqlHistory) znajduje się przykład do tego wpisu. Jest to rozbudowanie przykładu z wpisu o użyciu Temporal Table w Entity Framework. Po pobraniu przykładu należy w app.config ustawić connection string do testowej bazy.

Podsumowanie

Czasami wygenerowany przez Entity Framework SQL nie jest tym, czego potrzebujemy. Szczególnie gdy próbujemy zrobić coś, czego Entity Framework nie wspiera. W takiej sytuacji z pomocą przychodzą interceptory, które umożliwiają nam zmianę wygenerowanego SQL.

Myślę, że warto wiedzieć, jak wykorzystać interceptory. Mam nadzieję, że nie będziesz musiał zbyt często z nich korzystać. 🙂

1 thought on “Interceptory w Entity Framework

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *