Spis treści:

Kategoria:C#SQL ServerEntity Framework


Rozszerzone filtry w Entity Framework

LINQ i metody rozszerzające

- Baco, jak wygląda wasz dzień pracy?
- Rano wyprowadzam owce, wyciągam flaszkę i piję...
- Baco, ten wywiad będą czytać dzieci. Zamiast flaszka mówcie książka.
- Dobra. Rano wyprowadzam owce, wyciągam książkę i czytam. W południe przychodzi Jędrek ze swoją książką i razem czytamy jego książkę. Po południu idziemy do księgarni i kupujemy dwie książki, które czytamy do wieczora. A wieczorem idziemy do Franka i tam czytamy jego rękopisy.

Metody rozszerzające pojawiły się w języku C# w wersji 3.0 i od razu zyskały rzesze wyznawców. Głównie za sprawą LINQ (ang. Language Integrated Query) i idącymi równolegle z tą techniką uproszczonymi metodami dostępu do danych. Wspomniane dane mogły być w kolekcjach, w plikach XML, w bazie danych, a każde na swój sposób wyjątkowe. Widział Microsoft, że to co zrobił, było dobre. Programiści nauczyli się korzystać z tych metod. Niektórzy, bardziej ambitni, zaczęli nawet pisać własne metody rozszerzające. Niektórzy robili to lepiej, niektórzy gorzej, ale raz nadanego kierunku zmian nie dało się zmienić. Była to droga, z której nie było powrotu. Ci, którzy na początku nie byli przekonani, musieli dogonić tych, którzy uwierzyli pierwsi. Zwłaszcza, że po jakimś czasie pojawił się Entity Framework, mocno wspierany przez wspomnianą już firmę. Rzeczony Entity Framework również korzystał ze składni LINQ co pozwalało na zastosowanie tych samych technik, które były stosowane wcześniej. To między innymi metody rozszerzające i drzewa wyrażeń. Dziś pokażę, jak za pomocą tych technik otrzymać prosty i czytelny mechanizm filtrowania rekordów pobieranych z bazy danych. Przejdźmy do konkretów.

Przykładowa tabela SQL Server

Różne techniki najlepiej pokazać na konkretnym przykładzie. Ten będzie dotyczył hipotetycznego systemu zamówień i tabeli z produktami. Tabela może się wydać dziwna, ale jej kształt został nagięty w celu pokazania filtrów. Wszystkie mało istotne kolumny zostały pominięte. Popatrzmy na przykładowy skrypt tworzący tabelę zaprezentowany poniżej:

CREATE TABLE Orders
(
  ID int IDENTITY CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED,
  Name varchar(20) NOT NULL,
  Canceled bit NOT NULL,
  DiscountFrom date,
  DiscountTo date
)

INSERT Orders VALUES
('Wooden train', 0, NULL, NULL),
('Jumping rope', 1, NULL, NULL),
('Slingshot', 0, '01-01-2010', '01-01-2100')

Tabela zawiera trzy rekordy. Kluczowe z punktu widzenia przykładu jest kolumna Canceled, oznaczająca towary anulowane, wycofane ze sprzedaży, oraz para kolumn DiscountFrom oraz DiscountTo, oznaczająca okres promocji. Myślę, że nie ma się co rozwodzić nad tą tabelką. Czas przejść dalej i zająć się rzeczywistymi zapytaniami w Entity Framework i nakładanymi na te zapytania filtrami.

Zapytanie w Entity Framework z prostym filtrem

Przypuśćmy, że chcemy zwrócić tylko te rekordy, które nie zostały wycofane ze sprzedaży. Mówiąc prościej, tylko te, które mają atrybut Canceled ustawiony na 0 (false). Można to zrobić w sposób tradycyjny, dokładając sekcję where w zapytaniu, ale nie zrobię tego. Na tak prostym przykładzie pokażę filtr z wykorzystaniem drzewa wyrażeń. Łatwiej będzie zrozumieć przykład rozszerzony, do którego powoli zmierzam. Popatrzmy na definicję filtru:

public static IQueryable<T> Active<T>(this IQueryable<T> source)
{
    var type = typeof(T);
    var parameter = Expression.Parameter(type, "p");
    var stateProperty = type.GetProperty("Canceled");
    if (stateProperty != null)
    {
        //atrybut p.Canceled
        var property = Expression.Property(parameter, stateProperty.Name);
        //wyrażenie p.Canceled == false
        var where = Expression.Equal(property, Expression.Constant(false));
        //.Where(p => p.Canceled == false)
        Expression finalQuery = Expression.Call(typeof(Queryable), "Where", new Type[] { type }, source.Expression,
            Expression.Lambda<Func<T, bool>>(where, new ParameterExpression[] { parameter }));
        return source.Provider.CreateQuery<T>(finalQuery);
    }
    else
    {
        return source;
    }
}

Pokazana powyżej metoda rozszerzająca dokłada do typu jeden prosty warunek. Dałoby się to zrobić krócej, ale rozbiłem wszystko na drobne kroki. Jeden z kroków nie jest nawet wymagany, ale zabezpiecza przed wywołaniem metody na encji, która nie posiada atrybutu Canceled. Swoją drogą, warto w takiej sytuacji rozważyć zgłoszenie wyjątku - czasami nie chcemy wywoływać filtru na nieprawidłowym obiekcie i wolimy wcześniej o tym wiedzieć. Wszystko zależy od konkretnego rozwiązania i sytuacji. Wewnątrz instrukcji if budujemy wyrażenie, które zostanie przekazane do sekcji Where całego wyrażenia. Warto wiedzieć, że drzewo wyrażeń przetwarzane jest przez Entity Framework dopiero podczas wykonywania zapytania. Oznacza to, że kilka filtrów Where nałożonych sekwencyjne zostanie zgrupowanych i połączonych warunkiem AND. Podobnie grupowana jest sekcja Select. Jeżeli istnieją dwie takie sekcje, możemy (ale nie musimy) otrzymać w wyniku jedno płaskie zapytanie z atrybutami pobranymi z zewnętrznej instrukcji Select. Drzewo wyrażeń może być przekształcane na równoważne, to jest takie, z którego wygenerowane zapytanie SQL pobierze te same rekordy. Przechodząc na grunt matematyki, bardziej przyziemny, równoważne będą poniższe równania:

  • y - x = 2 + 3
  • y - x = 5
  • y = x + 5

Podobne przekształcenia, oczywiście w ramach swojej dziedziny, może wykonywać Entity Framework. Popatrzmy jeszcze na sposób wykorzystania napisanej przed chwilą metody rozszerzającej:

using (var dc = new OrdersModel())
{
    var items = from o in dc.Orders.Active()
                select o.Name;

    items.ToList().ForEach(Console.WriteLine);
}

W oknie konsoli wypisane zostaną następujące wartości:

Wooden train
Slingshot

Wspomniałem, że zapytanie generowane jest dopiero w momencie pobrania danych (tutaj będzie to instrukcja ToList()) i dopiero wtedy całe drzewo wyrażeń jest przekształcane. Dzięki takiemu rozwiązaniu filtry przekazywane są w postaci zapytania do bazy danych. Gdybyśmy podejrzeli zapytanie, które wysyłane jest do SQL Server, zobaczylibyśmy coś takiego:

SELECT 
    [Extent1].[Name] AS [Name]
    FROM [dbo].[Orders] AS [Extent1]
    WHERE 0 = [Extent1].[Canceled]

Pokazana powyżej technika może być w dowolny sposób rozszerzana. Wyrażenia filtrujące mogą być bardziej skomplikowane - mogą zawierać operatory logiczne, arytmetyczne, rzutowania, mogą być pogrupowane nawiasami. Ograniczają nas tylko zdolności. Przejdźmy do bardziej skomplikowanego przykładu.

Filtrowanie rekordów w promocji

Tym razem wyrażenie będzie bardziej skomplikowane. Spróbujemy pobrać te rekordy, dla których bieżąca data mieści się w zakresie DiscountFrom - DiscountTo. W języku SQL zapisalibyśmy to stosując operator BETWEEN lub łącząc operatorem logicznym warunki dolnego i górnego zakresu. W pokazanym przykładzie zastosuję tę drugą technikę:

//DateTime.Now (tłumaczone na SQL jako SysDateTime())
private static Expression NowExpression = Expression.Property(null, typeof(DateTime).GetProperty("Now"));
public static IQueryable<T> Bargain<T>(this IQueryable<T> source)
{
    var type = typeof(T);
    var parameter = Expression.Parameter(type, "p");

    var dateFromProperty = type.GetProperty("DiscountFrom");
    var dateToProperty = type.GetProperty("DiscountTo");
    if (dateFromProperty != null && dateToProperty != null)
    {
        //p.DiscountFrom
        var fromProperty = Expression.Property(parameter, dateFromProperty.Name);
        //p.DiscountFrom <= (DateTime?)DateTime.Now
        var fromExpression = Expression.LessThanOrEqual(fromProperty, Expression.Convert(NowExpression, typeof(DateTime?)));
        //p.DiscountTo
        var toProperty = Expression.Property(parameter, dateToProperty.Name);
        //p.DiscountTo >= (DateTime?)DateTime.Now
        var toExpression = Expression.GreaterThanOrEqual(toProperty, Expression.Convert(NowExpression, typeof(DateTime?)));
        //p.DiscountFrom <= DateTime.Now && p.DiscountTo >= DateTime.Now
        var andExpression = Expression.AndAlso(fromExpression, toExpression);

        Expression finalQuery = Expression.Call(typeof(Queryable), "Where", new Type[] { type }, source.Expression,
            Expression.Lambda<Func<T, bool>>(andExpression, new ParameterExpression[] { parameter }));
        return source.Provider.CreateQuery<T>(finalQuery);
    }
    else
    {
        return source;
    }
}

Kod jest bardzo podobny do poprzedniego. Różni się tylko wyrażeniem, które będzie przekazywane do sekcji WHERE. Poszczególne kroki budowania wyrażenia zostały opatrzone komentarzem, więc nie powinno być problemu ze zrozumieniem ich znaczenia. Drobnego wyjaśnienia wymaga też potrzeba rzutowania. Spowodowane jest to niezgodnością typów zgłaszaną przez obiekty służące do budowy drzewa wyrażeń. To, że typ DateTime i DateTime? można porównywać w kodzie to zasługa kompilatora. Ogólnie rzecz ujmując, typy te nie są zgodne. To dlatego trzeba jawnie wykonać rzutowanie. Popatrzmy jeszcze na sposób wywołania tego filtru w zapytaniu:

using (var dc = new OrdersModel())
{
    var items = from o in dc.Orders.Bargain()
                select o.Name;

    items.ToList().ForEach(Console.WriteLine);
}

Tym razem w oknie konsoli pojawi się tylko jeden rekord:

Slingshot

Aby dopełnić formalności pokażę jeszcze postać kodu SQL, który zostanie wygenerowany przez Entity Framework po przetworzeniu całego drzewa wyrażeń:

SELECT 
    [Extent1].[Name] AS [Name]
    FROM [dbo].[Orders] AS [Extent1]
    WHERE ([Extent1].[DiscountFrom] <=  CAST( SysDateTime() AS datetime2))
    AND ([Extent1].[DiscountTo] >=  CAST( SysDateTime() AS datetime2))

To nie wszystko. Podane tutaj filtry można łączyć korzystając z płynnego API.

Łączenie filtrów w jednym zapytaniu

Sposób łączenia filtrów będzie zapewne podyktowany frekwencją stosowania poszczególnych konstrukcji. Jeżeli często sięgamy po aktywne promocje, można filtr umieścić w jednej metodzie. Będzie to łatwiejsze w stosowaniu i krótsze w zapisie. Gdy filtry są stosowane niezależnie, można je umieścić jeden po drugim:

using (var dc = new OrdersModel())
{
    var items = from o in dc.Orders.Active().Bargain()
                select o.Name;

    items.ToList().ForEach(Console.WriteLine);
}

Warunki zapytania zostaną oczywiście połączone w jedną wynikową instrukcję SQL:

SELECT 
    [Extent1].[Name] AS [Name]
    FROM [dbo].[Orders] AS [Extent1]
    WHERE (0 = [Extent1].[Canceled])
    AND ([Extent1].[DiscountFrom] <=  CAST( SysDateTime() AS datetime2))
    AND ([Extent1].[DiscountTo] >=  CAST( SysDateTime() AS datetime2))

Postać zapytania SQL z punktu widzenia wydajności jest prawie idealna. Nie jest to zapytanie, które ręcznie napisałby doświadczony programista baz danych, ale jest to koszt płacony za luksus korzystania z narzędzi typu OR/M.

Obsługa typów z wartościami NULL

W poprzednim przykładzie pojawił się problem porównania typu przyjmującego NULL z konkretną wartością. Zostało to rozwiązane dzięki konwersji typów, tłumaczonej przez Entity Framework na instrukcję CAST w bazie danych. Obsługa wartości NULL nie zawsze musi być tak łatwa do obejścia. Wiadomo, że wartość NULL jest przez bazy danych traktowana szczególnie. Wszelkie porównania z NULL w zapytaniach dają wynik fałszywy, ale już w więzach integralności typu CHECK rezultat NULL oznacza wartość poprawną. Są jeszcze przełączniki w bazie, które definiują sposób porównywania wartości NULL co jeszcze bardziej potrafi zagmatwać całą sytuacjęMowa tutaj o SET ANSI_NULLS. Dokumentacja SQL Server jasno wskazuje, że jest to instrukcja wycofywana z użycia i jest to bardzo dobra informacja. Trzeba przyznać, że powodowała ona więcej problemów niż pożytku.. Wartości przyjmujące null w Entity Framework powinny być zatem starannie obsłużone. Jak rozpoznać, czy atrybut może przyjmować wartość null? Można skorzystać z pokazanej tutaj metody:

static bool IsNullableType(Type t)
{
    return t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>);
}

Dzięki sprawdzeniu warunku można odpowiednio zareagować na potencjalnie puste pola i wygenerować nieco inne drzewo wyrażeń. To szczególnie istotne w przypadku porównań z wartością null, która sam w sobie nie ma żadnego konkretnego typu i nie da się bez szerszego kontekstu określić czy ten null to pusty łańcuch znaków, czy też może reprezentuje pustą wartość typu int?.

Podsumowanie

Odpowiednio zaprojektowany system rozszerzeń może w znaczny sposób uprościć zapytania. Jeżeli jakiś warunek powtarza się w zapytaniach wielokrotnie, warto przenieść go do jakiejś zewnętrznej metody rozszerzającej. Metody takie można łączyć, a sam rezultat może być złączany z innymi tabelami. Te inne tabele również mogą mieć swoje filtry. Możliwości takich filtrów idą dużo dalej niż zostało to tutaj pokazane. Filtr może przecież pobrać identyfikator zalogowanego użytkownika i zwrócić tylko te rekordy, które są do niego przypisane. Można pójść jeszcze dalej i rozszerzyć tę funkcjonalność o możliwość definiowania własnych, konfigurowalnych i dynamicznie budowanych filtrów. Znów, wszystko zależy od potrzeb. Każda taka dodatkowa funkcja wiąże się bowiem z jakimś nakładem pracy.

Kategoria:C#SQL ServerEntity Framework

, 2014-01-28

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
puściłem benta i leci klockiem w pomieszczeniu, w którym kodujemy
Dzieki za rozjasnienie zagadnienia upsert. wlasnie sie ucze programowania :).