Database
 sql >> Baza danych >  >> RDS >> Database

Używanie wyrażeń do filtrowania danych bazy danych

Chciałbym zacząć od opisu problemu, który napotkałem. W bazie danych znajdują się encje, które muszą być wyświetlane jako tabele w interfejsie użytkownika. Entity Framework służy do uzyskiwania dostępu do bazy danych. Istnieją filtry dla tych kolumn tabeli.

Konieczne jest napisanie kodu do filtrowania jednostek według parametrów.

Na przykład istnieją dwie jednostki:Użytkownik i Produkt.

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Załóżmy, że musimy filtrować użytkowników i produkty według nazwy. Tworzymy metody do filtrowania każdej jednostki.

public IQueryable<User> FilterUsersByName(IQueryable<User> users, string text)
{
    return users.Where(user => user.Name.Contains(text));
}

public IQueryable<Product> FilterProductsByName(IQueryable<Product> products, string text)
{
    return products.Where(product => product.Name.Contains(text));
}

Jak widać, te dwie metody są prawie identyczne i różnią się tylko właściwością encji, według której filtruje dane.

Może to być wyzwaniem, jeśli mamy dziesiątki encji z dziesiątkami pól, które wymagają filtrowania. Złożoność polega na obsłudze kodu, bezmyślnym kopiowaniu, aw rezultacie powolnym rozwoju i wysokim prawdopodobieństwie błędu.

Parafrazując Fowlera, zaczyna pachnieć. Chciałbym napisać coś standardowego zamiast powielania kodu. Na przykład:

public IQueryable<User> FilterUsersByName(IQueryable<User> users, string text)
{
    return FilterContainsText(users, user => user.Name, text);
}

public IQueryable<Product> FilterProductsByName(IQueryable<Product> products, string text)
{
    return FilterContainsText(products, propduct => propduct.Name, text);
}

public IQueryable<TEntity> FilterContainsText<TEntity>(IQueryable<TEntity> entities,
 Func<TEntity, string> getProperty, string text)
{
    return entities.Where(entity => getProperty(entity).Contains(text));
}

Niestety, jeśli spróbujemy filtrować:

public void TestFilter()
{
    using (var context = new Context())
    {
            var filteredProducts = FilterProductsByName(context.Products, "name").ToArray();
    }
}

Otrzymamy błąd «Metoda testowa ExpressionTests.ExpressionTest.TestFilter zgłosił wyjątek:
System.NotSupportedException :typ węzła wyrażenia LINQ „Invoke” nie jest obsługiwany w LINQ to Entities.

Wyrażenia

Sprawdźmy, co poszło nie tak.

Metoda Where akceptuje parametr typu Expression>. Dlatego Linq współpracuje z drzewami wyrażeń, za pomocą których buduje zapytania SQL, a nie z delegatami.

Expression opisuje drzewo składni. Aby lepiej zrozumieć ich strukturę, rozważ wyrażenie, które sprawdza, czy nazwa jest równa wierszowi.

Expression<Func<Product, bool>> expected = product => product.Name == "target";

Podczas debugowania możemy zobaczyć strukturę tego wyrażenia (właściwości klucza są zaznaczone na czerwono).

Mamy następujące drzewo:

Kiedy przekazujemy delegata jako parametr, generowane jest inne drzewo, które wywołuje metodę Invoke na parametrze (delegata) zamiast wywoływania właściwości jednostki.

Kiedy Linq próbuje zbudować zapytanie SQL według tego drzewa, nie wie, jak zinterpretować metodę Invoke i wyrzuca NotSupportedException.

W związku z tym naszym zadaniem jest zastąpienie rzutowania na właściwość encji (część drzewa oznaczona na czerwono) wyrażeniem przekazywanym za pomocą tego parametru.

Spróbujmy:

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"

Teraz widzimy błąd «Oczekiwana nazwa metody» na etapie kompilacji.

Problem polega na tym, że wyrażenie jest klasą reprezentującą węzły drzewa składni, a nie delegatem i nie można go wywołać bezpośrednio. Teraz głównym zadaniem jest znalezienie sposobu na utworzenie wyrażenia przekazującego do niego inny parametr.

Gość

Po krótkim wyszukiwaniu w Google znalazłem rozwiązanie podobnego problemu na StackOverflow.

Do pracy z wyrażeniami służy klasa ExpressionVisitor, która używa wzorca Visitor. Jest przeznaczony do przechodzenia przez wszystkie węzły drzewa wyrażeń w kolejności parsowania drzewa składni i umożliwia ich modyfikację lub zamiast tego zwracanie innego węzła. Jeśli ani węzeł, ani jego węzły podrzędne nie zostaną zmienione, zwracane jest oryginalne wyrażenie.

Dziedzicząc z klasy ExpressionVisitor możemy zastąpić dowolny węzeł drzewa wyrażeniem, które przekazujemy poprzez parametr. Dlatego musimy umieścić w drzewie etykietę węzła, którą zastąpimy parametrem. Aby to zrobić, napisz metodę rozszerzenia, która będzie symulować wywołanie wyrażenia i będzie znacznikiem.

public static class ExpressionExtension
{
    public static TFunc Call<TFunc>(this Expression<TFunc> expression)
    {
        throw new InvalidOperationException("This method should never be called. It is a marker for replacing.");
    }
}

Teraz możemy zastąpić jedno wyrażenie innym

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";

Konieczne jest napisanie gościa, który zastąpi metodę Call swoim parametrem w drzewie wyrażeń:

public class SubstituteExpressionCallVisitor : ExpressionVisitor
{
    private readonly MethodInfo _markerDesctiprion;

    public SubstituteExpressionCallVisitor()
    {
        _markerDesctiprion =
            typeof(ExpressionExtension).GetMethod(nameof(ExpressionExtension.Call)).GetGenericMethodDefinition();
    }

    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        if (IsMarker(node))
        {
            return Visit(ExtractExpression(node));
        }
        return base.VisitMethodCall(node);
    }

    private LambdaExpression ExtractExpression(MethodCallExpression node)
    {
        var target = node.Arguments[0];
        return (LambdaExpression)Expression.Lambda(target).Compile().DynamicInvoke();
    }

    private bool IsMarker(MethodCallExpression node)
    {
        return node.Method.IsGenericMethod && node.Method.GetGenericMethodDefinition() == _markerDesctiprion;
    }
}

Możemy wymienić nasz znacznik:

public static Expression<TFunc> SubstituteMarker<TFunc>(this Expression<TFunc> expression)
{
    var visitor = new SubstituteExpressionCallVisitor();
    return (Expression<TFunc>)visitor.Visit(expression);
}

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product).Contains("123");
Expression<Func<Product, bool>> finalFilter = filter.SubstituteMarker();

Podczas debugowania widzimy, że wyrażenie nie jest tym, czego oczekiwaliśmy. Filtr nadal zawiera metodę Invoke.

Faktem jest, że wyrażenia parameterGetter i finalFilter używają dwóch różnych argumentów. Dlatego musimy zastąpić argument w parameterGetter argumentem w finalFilter. W tym celu tworzymy kolejnego gościa:

Wynik jest następujący:

public class SubstituteParameterVisitor : ExpressionVisitor
{
    private readonly LambdaExpression _expressionToVisit;
    private readonly Dictionary<ParameterExpression, Expression> _substitutionByParameter;

    public SubstituteParameterVisitor(Expression[] parameterSubstitutions, LambdaExpression expressionToVisit)
    {
        _expressionToVisit = expressionToVisit;
        _substitutionByParameter = expressionToVisit
                .Parameters
                .Select((parameter, index) => new {Parameter = parameter, Index = index})
                .ToDictionary(pair => pair.Parameter, pair => parameterSubstitutions[pair.Index]);
    }

    public Expression Replace()
    {
        return Visit(_expressionToVisit.Body);
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        Expression substitution;
        if (_substitutionByParameter.TryGetValue(node, out substitution))
        {
            return Visit(substitution);
        }
        return base.VisitParameter(node);
    }
}

public class SubstituteExpressionCallVisitor : ExpressionVisitor
{
    private readonly MethodInfo _markerDesctiprion;

    public SubstituteExpressionCallVisitor()
    {
        _markerDesctiprion = typeof(ExpressionExtensions)
            .GetMethod(nameof(ExpressionExtensions.Call))
            .GetGenericMethodDefinition();
    }

    protected override Expression VisitInvocation(InvocationExpression node)
    {
        var isMarkerCall = node.Expression.NodeType == ExpressionType.Call &&
                           IsMarker((MethodCallExpression) node.Expression);
        if (isMarkerCall)
        {
            var parameterReplacer = new SubstituteParameterVisitor(node.Arguments.ToArray(),
                Unwrap((MethodCallExpression) node.Expression));
            var target = parameterReplacer.Replace();
            return Visit(target);
        }
        return base.VisitInvocation(node);
    }

    private LambdaExpression Unwrap(MethodCallExpression node)
    {
        var target = node.Arguments[0];
        return (LambdaExpression)Expression.Lambda(target).Compile().DynamicInvoke();
    }

    private bool IsMarker(MethodCallExpression node)
    {
        return node.Method.IsGenericMethod &&
               node.Method.GetGenericMethodDefinition() == _markerDesctiprion;
    }
}

Teraz wszystko działa tak, jak powinno i wreszcie możemy napisać naszą metodę filtracji

public IQueryable<TEntity> FilterContainsText<TEntity>(IQueryable<TEntity> entities, Expression<Func<TEntity, string>> getProperty, string text)
{
    Expression<Func<TEntity, bool>> filter = entity => getProperty.Call()(entity).Contains(text);
    return entities.Where(filter.SubstituteMarker());
}

Wniosek

Podejście z zastępowaniem wyrażeń może służyć nie tylko do filtrowania, ale także do sortowania i dowolnego zapytania do bazy danych.

Ponadto ta metoda umożliwia przechowywanie wyrażeń wraz z logiką biznesową oddzielnie od zapytań do bazy danych.

Możesz spojrzeć na kod na GitHub.

Ten artykuł jest oparty na odpowiedzi StackOverflow.


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. Pytania i odpowiedzi z naszej serii webinariów Parameter Sniffing

  2. Podobieństwa i różnice między funkcjami RANK, DENSE_RANK i ROW_NUMBER

  3. Operatory SET w SQL

  4. Jak nie wyświetlać duplikatów w SQL?

  5. SQL ALTER TABLE dla początkujących