Queries zur Laufzeit dynamisch erstellen 18.03.2024, 00:00 Uhr

Frag mich nicht immer dasselbe !

Dynamische Abfragen ermöglichen eine große Flexibilität beim Datenabruf.
(Quelle: dotnetpro)
Anders als bei statischen Queries, die bereits zur Kompilierungszeit definiert sind und unverändert bleiben, können Entwickler durch den Einsatz von dynamischen Queries mehr Flexibilität und Vielseitigkeit in ihr System bringen, zum Beispiel wenn es um die Erstellung flexibler Filterbedingungen geht (vergleiche [1]).
Doch bevor wir die dynamischen Abfragen in den Fokus rücken, werden wir zunächst die sogenannten Expression Trees betrachten – sie spielen in diesem Kontext die tragende Rolle (vergleiche [2]).

Expression Trees erklärt

Es handelt sich hierbei um die Möglichkeit, Programmcode als Daten in der Form eines Baums darzustellen, wobei jeder Knoten des Baums Folgendes repräsentieren kann:
  • eine Operation
  • eine Variable
  • eine Konstante
Eingesetzt werden Expression Trees, wenn es darum geht, zur Laufzeit Code zu manipulieren. Unter anderem also dann, wenn dieser transformiert oder in eine andere Sprache umgesetzt werden soll, wie zum Beispiel bei der Abfrage einer Datenbank.

Erstellung von Expression Trees

Erstellt werden kann ein Expression Tree zum Beispiel aus einer Lambda Expression oder unter Verwendung der Expression-Klasse. Bild 1 zeigt die erste Variante, bei der die Lambda Expression zwei Integers entgegennimmt und deren Summe als Rückgabe liefert. Die Ausführung entspricht syntaktisch einem Funktionsaufruf.
Eine Lambda Expression als Ausgangsbasis (Bild 1)
Quelle: Autor
Um aus einer solchen Lambda Expression einen neuen Expression Tree zu konstruieren, wird der Delegate TDelegate ersetzt durch Expression<TDelegate> (siehe Bild 2). Der Tooltip liefert einen schönen Hinweis auf den beschreibenden Charakter des Expression Trees.
Expression Tree aus einer Lambda Expression (Bild 2)
Quelle: Autor
Wie schon erwähnt, kann der Expression-Tree auch durch die Factory-Funktionen der Klasse Expression erstellt werden, wie in Bild 3 zu sehen. Hier wird zunächst für jeden Summanden ein Parameter erstellt. Die Operation zum Addieren der Summanden wird durch den folgenden Aufruf festgelegt:
Expression mit Factory-Funktionen erstellen (Bild 3)
Quelle: Autor

BinaryExpression body = Expression.Add(
  summand1, summand2);
Nun soll eine Expression erstellt werden, die den gleichen Lambda-Ausdruck enthält wie zuvor. Dies geschieht durch folgenden Funktionsaufruf:

Expression<Func<int, int, int>> sum = Expression.Lambda<
  Func<int, int, int>>(body, summand1, summand2);
Die Expression kann im Gegensatz zu einem Delegate nicht direkt ausgeführt werden, da die Expression lediglich die Daten beziehungsweise die Beschreibung dessen enthält, was die notwendige Transformation in die Zielsprache – zum Beispiel SQL – enthalten soll. Die Expression muss zunächst kompiliert werden. Bild 4 zeigt die Darstellung der Daten (Beschreibung) der Expression und das erwartete Resultat, nachdem die Expression übersetzt und ausgeführt wurde.
Die erstellte und kompilierte Expression (Bild 4)
Quelle: Autor
Die Markierungen in Bild 5 sollen darauf hinweisen, dass die beiden Parameter summand1 und summand2 sowohl im Body der Expression in den Properties Left und Right zur Verfügung stehen als auch in der Parameterliste Parameters.
Expression Tree im Detail (Bild 5)
Quelle: Autor
Die gesamte Expression kann als Root betrachtet werden, die auf der zweiten Ebene den Body (eine BinaryExpression) enthält. Auf der dritten Ebene sind die beiden Parameter summand1 und summand2 in den Properties Left und Right zu finden.

Einsatz von Expressions

Eingesetzt werden Expressions etwa beim Entity Framework, bei dem mittels der Lambda-Ausdrücke die Abfrage formuliert wird. Beispielweise nimmt die Where-Funktion eine Expression für die Filterbedingung entgegen. Der zugehörige Datenbankprovider nimmt diese Expression entgegen und kann sich dann vom Root der Expression durch die darunterliegenden Ebenen arbeiten und aus den Daten, etwa summand1 und summand2, die passende SQL-Abfrage formulieren; dies wird als Query Expression Translation bezeichnet.
In Bild 6 wird mittels der Funk­tion ToQueryString() – sie ist lediglich für Debugging-Zwecke gedacht – eine Ausgabe generiert, die dies nochmals verdeutlicht.
Query Expression Translation mit der Funktion ToQueryString() (Bild 6)
Quelle: Autor
Ein weiteres Beispiel wäre, wenn Code aus Eingabedaten generiert werden soll. Wenn zum Beispiel der Eingabestring =2*5 geparst wird und aus diesem ein Expression Tree mit zwei Parametern für die Werte 2 und 5 erstellt und kompiliert wird.

Einschränkungen bei ­Expression Trees

Zu beachten sind jedoch die Einschränkungen der Expres­sion. So können zum Beispiel Expression Trees keine await-Expressions oder async-Lambda-Expressions enthalten. Die gesamte Liste der Limitierungen ist unter [3] verfügbar.

Performance-Betrachtungen mit
Expression Trees

Beim Einsatz von Expression Trees muss berücksichtigt werden, dass die Erstellung und Kompilierung einen gewissen Overhead erzeugt; immerhin muss der Speicher für die einzelnen Knoten reserviert werden, und beim Kompilieren wird IL-Code generiert, der an eine dynamische Assembly übergeben werden muss.
Es ist offensichtlich, dass sich die beschriebenen Schritte negativ auf die Performance der Anwendung auswirken können. Daher empfiehlt es sich, Folgendes bei der technischen Umsetzung zu beachten:
  • Cachen des Expression Trees.
  • Der Kompilierungsaufwand wächst mit der Größe des Expression Trees.
  • Sie sollten nur dann eingesetzt werden, wenn es wirklich notwendig ist.

Dynamische Abfragen erstellen

Die bisherigen Abschnitte haben zum Teil bereits gezeigt, wie mit dem Expression-API dynamische Abfragen generiert werden können. Dies soll im Folgenden vertieft werden.
Listing 1 zeigt die Erstellung einer Expres­sion, die dem Vergleich dient. Der Ausdruck Expression.Parameter(typeof(Customer), c) repräsentiert den Eingabeparameter, in diesem Beispiel die Entität Customer, die bezeichnet wird als c. Mit Expression.Property(param, propertyName) wird eine Eigenschaft für den zuvor definierten Ein­gabeparameter durch den Parameternamen definiert. Mit Expression.Constant(val) wird ein konstanter Wert für den Vergleich definiert, und Expression.Equal(member, constant) generiert eine BinaryExpression, um den Vergleich zwischen dem Member und dem konstanten Wert durchzuführen.
Listing 1: Eine Expression für den Vergleich
private static Expression<Func<Customer, bool>> 
    EqualExpression(string propertyName, object val)
{
  var param = Expression.Parameter(
    typeof(Customer), "c");
  var member = Expression.Property(
    param, propertyName);
  var constant = Expression.Constant(val);
  var body = Expression.Equal(member, constant);
  return Expression.Lambda<Func<Customer, bool>>(
    body, param);
}
Abschließend wird eine Lambda-Funktion erstellt. Bild 7 zeigt die zugehörige Ausführung.
Die Equal-Expression (Bild 7)
Quelle: Autor
Als nächstes Beispiel sollen mehrere Attribute für den Vergleich berücksichtigt werden. Die Implementierung aus Listing 2 nimmt hierfür eine Liste von Key-Value-Paaren in Form eines Dictionarys entgegen. In der Iteration wird gegebenenfalls die zusätzliche Prüfung berücksichtigt.
Listing 2: Mehrere Filterattribute
public static Expression<Func<Customer, bool>> 
    CreateEqualExpression(IDictionary<string, 
    object> compareAttributes)
{
  var param = Expression.Parameter(typeof(Customer), 
    "c");
  Expression body = null;
  foreach (var compareAttribute in 
      compareAttributes)
  {
    var member = Expression.Property(
      param, compareAttribute.Key);
    var constant = Expression.Constant(
      compareAttribute.Value);
    var expression = Expression.Equal(
      member, constant);
    body = body == null ? expression : 
      Expression.AndAlso(body, expression);
  }
  return Expression.Lambda<Func<Customer, bool>>(
    body, param);
}
Die Verwendung mit der generierten Ausgabe zeigt Bild 8.
Vergleich mit mehreren Attributen (Bild 8)
Quelle: Autor

Empfehlungen für die Nutzung
von dynamischen Abfragen

Wie bereits erwähnt, ist die Kompilierung einer Expression aufwendig. Daher empfiehlt es sich, diese zu cachen. Listing 3 zeigt einen Vorschlag für einen solchen Cache.
Listing 3: Cache für kompilierte Expressions
internal static class ExpressionCache
{
  private static readonly Dictionary<int, Delegate> 
    _hashToDelegate = new Dictionary<int, Delegate>();
  public static Func<T, bool> GetExpression<T>(
      Expression<Func<T, bool>> expression)
  {
    int hash = expression.GetHashCode();
    if (_hashToDelegate.TryGetValue(hash, 
        out var @delegate))
      return (Func<T, bool>) @delegate;
    var compiled = expression.Compile();
    hashToDelegate[hash] = compiled;
    return compiled;
  }
}
Es sollten NullReferenceExceptions vermieden werden. In Bild 9 sind die relevanten Anpassungen markiert; es erfolgt ein Vergleich, ob der propertyName mit dem konstanten Wert null übereinstimmt. Wenn dies der Fall ist, wird die Entität nicht berücksichtigt. Bild 10 zeigt die generierte Ausgabe.
Vermeidung von NullReferenceExceptions (Bild 9)
Quelle: Autor
NullReferences werden vermieden (Bild 10)
Quelle: Autor

Fazit

In diesem Beitrag wurden die deklarativen Aspekte von Expressions betrachtet und beschrieben, wie durch deren Nutzung dynamische Abfragen erstellt werden können. Neben der Erstellung von eigenen Expressions wurde noch auf einige Best Practices hingewiesen.
Dokumente
Artikel als PDF herunterladen


Das könnte Sie auch interessieren