Vom Laufzeitzustand abhängige Abfrage

In den meisten LINQ-Abfragen wird die allgemeine Form der Abfrage im Code festgelegt. Sie können Elemente mithilfe einer where-Klausel filtern, die Ausgabeauflistung mithilfe von orderby sortieren, Elemente gruppieren oder eine Berechnung durchführen. Ihr Code stellt möglicherweise Parameter für den Filter oder den Sortierschlüssel oder andere Ausdrücke bereit, die Teil der Abfrage sind. Die Gesamtform der Abfrage kann sich jedoch nicht ändern. In diesem Artikel lernen Sie Techniken zum Verwenden von System.Linq.IQueryable<T>-Schnittstellen und -Typen kennen, die es implementieren, um die Form einer Abfrage zur Laufzeit zu ändern.

Sie verwenden diese Techniken, um Abfragen zur Laufzeit zu erstellen, wobei eine benutzende Person oder ein Laufzeitzustand die Abfragemethoden ändert, die Sie als Teil der Abfrage verwenden möchten. Sie möchten die Abfrage durch Hinzufügen, Entfernen oder Ändern von Abfrageklauseln bearbeiten.

Hinweis

Stellen Sie sicher, dass Sie using System.Linq.Expressions; und using static System.Linq.Expressions.Expression; am Anfang Ihrer .cs-Datei hinzufügen.

Der folgende Code definiert eine IQueryable- oder IQueryable<T>-Schnittstelle für eine Datenquelle:

string[] companyNames = [
    "Consolidated Messenger", "Alpine Ski House", "Southridge Video",
    "City Power & Light", "Coho Winery", "Wide World Importers",
    "Graphic Design Institute", "Adventure Works", "Humongous Insurance",
    "Woodgrove Bank", "Margie's Travel", "Northwind Traders",
    "Blue Yonder Airlines", "Trey Research", "The Phone Company",
    "Wingtip Toys", "Lucerne Publishing", "Fourth Coffee"
];

// Use an in-memory array as the data source, but the IQueryable could have come
// from anywhere -- an ORM backed by a database, a web request, or any other LINQ provider.
IQueryable<string> companyNamesSource = companyNames.AsQueryable();
var fixedQry = companyNames.OrderBy(x => x);

Bei jeder Ausführung des vorherigen Codes wird genau dieselbe Abfrage ausgeführt. Erfahren Sie, wie Sie die Abfrage erweitern oder ändern. Eine IQueryable-Schnittstelle verfügt im Grunde über zwei Komponenten:

  • Expression: eine sprach- und datenquellenagnostische Darstellung der Komponenten der aktuellen Abfrage in Form einer Ausdrucksbaumstruktur.
  • Provider: eine Instanz eines LINQ-Anbieters, die weiß, wie die aktuelle Abfrage in einem Wert oder einer Wertegruppe materialisiert werden soll.

Im Kontext dynamischer Abfragen bleibt der Anbieter normalerweise immer gleich. Es ist die Ausdrucksbaumstruktur, die sich von Abfrage zu Abfrage unterscheidet.

Ausdrucksbaumstrukturen sind unveränderlich. Wenn Sie eine andere Ausdrucksbaumstruktur (und somit eine andere Abfrage) möchten, müssen Sie die bestehende Struktur in eine neue Ausdrucksbaumstruktur übersetzen. In den folgenden Abschnitten werden die genauen Verfahren für das unterschiedliche Abfragen in Abhängigkeit vom Laufzeitzustand beschrieben:

  • Verwenden des Laufzeitzustands innerhalb der Ausdrucksbaumstruktur
  • Aufrufen weiterer LINQ-Methoden
  • Variieren der Ausdrucksbaumstruktur, die an die LINQ-Methoden übergeben wird
  • Erstellen einer Expression<TDelegate>-Ausdrucksbaumstruktur mithilfe der Factorymethoden in Expression
  • Hinzufügen von Methodenaufrufknoten zu einer Ausdrucksbaumstruktur von IQueryable
  • Erstellen von Zeichenfolgen und Verwenden der dynamischen LINQ-Bibliothek

Jede dieser Techniken bietet mehr Möglichkeiten, allerdings auf Kosten einer höheren Komplexität.

Verwenden des Laufzeitzustands innerhalb der Ausdrucksbaumstruktur

Der einfachste Weg, eine dynamische Abfrage durchzuführen, besteht darin, direkt in der Abfrage über eine geschlossene Variable, wie length im folgenden Codebeispiel, auf den Laufzeitzustand zu verweisen:

var length = 1;
var qry = companyNamesSource
    .Select(x => x.Substring(0, length))
    .Distinct();

Console.WriteLine(string.Join(",", qry));
// prints: C, A, S, W, G, H, M, N, B, T, L, F

length = 2;
Console.WriteLine(string.Join(",", qry));
// prints: Co, Al, So, Ci, Wi, Gr, Ad, Hu, Wo, Ma, No, Bl, Tr, Th, Lu, Fo

Die interne Ausdrucksbaumstruktur (und damit die Abfrage) wurde nicht geändert. Die Abfrage gibt nur andere Werte zurück, weil der Wert von length modifiziert wurde.

Aufrufen weiterer LINQ-Methoden

Im Allgemeinen führen die integrierten LINQ-Methoden in Queryable zwei Schritte aus:

  • Umschließen der aktuellen Ausdrucksbaumstruktur in einem MethodCallExpression-Objekt, das den Methodenaufruf darstellt
  • Zurückübergeben der umschließenden Ausdrucksbaumstruktur an den Anbieter, um entweder über die Methode IQueryProvider.Execute des Anbieters einen Wert oder über die Methode IQueryProvider.CreateQuery ein übersetztes Abfrageobjekt zurückzugeben

Sie können die ursprüngliche Abfrage durch das Ergebnis einer Methode ersetzen, die System.Linq.IQueryable<T> zurückgibt, um eine neue Abfrage zu erhalten. Sie können den Laufzeitzustand wie im folgenden Beispiel verwenden:

// bool sortByLength = /* ... */;

var qry = companyNamesSource;
if (sortByLength)
{
    qry = qry.OrderBy(x => x.Length);
}

Variieren der Ausdrucksbaumstruktur, die an die LINQ-Methoden übergeben wird

Abhängig vom Laufzeitzustand können Sie verschiedene Ausdrücke an die LINQ-Methoden übergeben:

// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>> expr = (startsWith, endsWith) switch
{
    ("" or null, "" or null) => x => true,
    (_, "" or null) => x => x.StartsWith(startsWith),
    ("" or null, _) => x => x.EndsWith(endsWith),
    (_, _) => x => x.StartsWith(startsWith) || x.EndsWith(endsWith)
};

var qry = companyNamesSource.Where(expr);

Sie sollten die verschiedenen Unterausdrücke außerdem mit einer anderen Bibliothek wie PredicateBuilder von LinqKit erstellen:

// This is functionally equivalent to the previous example.

// using LinqKit;
// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>>? expr = PredicateBuilder.New<string>(false);
var original = expr;
if (!string.IsNullOrEmpty(startsWith))
{
    expr = expr.Or(x => x.StartsWith(startsWith));
}
if (!string.IsNullOrEmpty(endsWith))
{
    expr = expr.Or(x => x.EndsWith(endsWith));
}
if (expr == original)
{
    expr = x => true;
}

var qry = companyNamesSource.Where(expr);

Erstellen von Ausdrucksbaumstrukturen und Abfragen mit Factorymethoden

In allen bisherigen Beispielen waren der Elementtyp zur Kompilierzeit, string, und somit auch der Typ der Abfrage, IQueryable<string>, bekannt. Möglicherweise müssen Sie einer Abfrage eines beliebigen Elementtyps Komponenten hinzufügen bzw. je nach Elementtyp verschiedene Komponenten verwenden. Sie können Ausdrucksbaumstrukturen mithilfe der Factorymethoden in System.Linq.Expressions.Expression von Grund auf neu erstellen und so den Ausdruck zur Laufzeit auf einen bestimmten Elementtyp zuschneiden.

Erstellen eines Expression<TDelegate>-Objekts

Wenn Sie einen Ausdruck erstellen, der an eine der LINQ-Methoden übergeben werden soll, erstellen Sie tatsächlich eine Instanz von System.Linq.Expressions.Expression<TDelegate>, wobei TDelegate ein Delegattyp wie Func<string, bool> oder Action oder ein benutzerdefinierter Delegattyp ist.

System.Linq.Expressions.Expression<TDelegate> erbt von einem LambdaExpression-Objekt, das einen kompletten Lambdaausdruck wie im folgenden Beispiel darstellt:

Expression<Func<string, bool>> expr = x => x.StartsWith("a");

LambdaExpression verfügt über zwei Komponenten:

  1. Eine Parameterliste, (string x), die von der Parameters-Eigenschaft dargestellt wird.
  2. Einen Textkörper, x.StartsWith("a"), der durch die Body-Eigenschaft dargestellt wird.

Die grundlegenden Schritte zum Erstellen eines Expression<TDelegate>-Objekts lauten wie folgt:

  1. Definieren Sie ParameterExpression-Objekte für jeden der Parameter (sofern vorhanden) im Lambdaausdruck mithilfe der Parameter-Factorymethode.
    ParameterExpression x = Parameter(typeof(string), "x");
    
  2. Erstellen Sie den Text von LambdaExpression mit den von Ihnen definierten ParameterExpression und den Factorymethoden unter Expression. Ein Ausdruck, der x.StartsWith("a") darstellt, könnte beispielsweise wie folgt erstellt werden:
    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", [typeof(string)])!,
        Constant("a")
    );
    
  3. Umschließen Sie Parameter und Textkörper mit einer Überladung der Lambda-Factorymethode mit einem zur Kompilierzeit typisierten Expression<TDelegate>-Objekt:
    Expression<Func<string, bool>> expr = Lambda<Func<string, bool>>(body, x);
    

In den folgenden Abschnitten wird ein Szenario beschrieben, in dem Sie möglicherweise ein Expression<TDelegate> konstruieren möchten, das Sie an eine LINQ-Methode übergeben. Es enthält ein vollständiges Beispiel für die Verwendung der Factorymethoden.

Erstellen einer vollständigen Abfrage zur Laufzeit

Sie möchten Abfragen schreiben, die mit mehreren Entitätstypen funktionieren:

record Person(string LastName, string FirstName, DateTime DateOfBirth);
record Car(string Model, int Year);

Diese Entitätstypen sollen nun gefiltert und nur die Entitäten zurückgegeben werden, die einen bestimmten Text in einem der string-Felder aufweisen. Für Person sollen die Eigenschaften FirstName und LastName durchsucht werden:

string term = /* ... */;
var personsQry = new List<Person>()
    .AsQueryable()
    .Where(x => x.FirstName.Contains(term) || x.LastName.Contains(term));

Für Car soll jedoch nur die Eigenschaft Model durchsucht werden:

string term = /* ... */;
var carsQry = new List<Car>()
    .AsQueryable()
    .Where(x => x.Model.Contains(term));

Sie können zwar eine benutzerdefinierte Funktion für IQueryable<Person> und eine weitere für IQueryable<Car> schreiben, die folgende Funktion fügt diese Filter jedoch unabhängig vom jeweiligen Elementtyp zu jeder vorhandenen Abfrage hinzu.

// using static System.Linq.Expressions.Expression;

IQueryable<T> TextFilter<T>(IQueryable<T> source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }

    // T is a compile-time placeholder for the element type of the query.
    Type elementType = typeof(T);

    // Get all the string properties on this specific type.
    PropertyInfo[] stringProperties = elementType
        .GetProperties()
        .Where(x => x.PropertyType == typeof(string))
        .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Get the right overload of String.Contains
    MethodInfo containsMethod = typeof(string).GetMethod("Contains", [typeof(string)])!;

    // Create a parameter for the expression tree:
    // the 'x' in 'x => x.PropertyName.Contains("term")'
    // The type of this parameter is the query's element type
    ParameterExpression prm = Parameter(elementType);

    // Map each property to an expression tree node
    IEnumerable<Expression> expressions = stringProperties
        .Select(prp =>
            // For each property, we have to construct an expression tree node like x.PropertyName.Contains("term")
            Call(                  // .Contains(...) 
                Property(          // .PropertyName
                    prm,           // x 
                    prp
                ),
                containsMethod,
                Constant(term)     // "term" 
            )
        );

    // Combine all the resultant expression nodes using ||
    Expression body = expressions
        .Aggregate((prev, current) => Or(prev, current));

    // Wrap the expression body in a compile-time-typed lambda expression
    Expression<Func<T, bool>> lambda = Lambda<Func<T, bool>>(body, prm);

    // Because the lambda is compile-time-typed (albeit with a generic parameter), we can use it with the Where method
    return source.Where(lambda);
}

Da die TextFilter-Funktion eine IQueryable<T> (und nicht nur eine IQueryable) nimmt und zurückgibt, können Sie weitere zur Kompilierzeit typisierte Abfrageelemente nach dem Textfilter hinzufügen.

var qry = TextFilter(
        new List<Person>().AsQueryable(),
        "abcd"
    )
    .Where(x => x.DateOfBirth < new DateTime(2001, 1, 1));

var qry1 = TextFilter(
        new List<Car>().AsQueryable(),
        "abcd"
    )
    .Where(x => x.Year == 2010);

Hinzufügen von Methodenaufrufknoten zur Ausdrucksbaumstruktur von IQueryable<TDelegate>

Wenn Sie IQueryable anstelle von IQueryable<T> verwenden, können Sie die generischen LINQ-Methoden nicht direkt aufrufen. Eine Alternative besteht darin, die innere Ausdrucksbaumstruktur wie im obigen Beispiel zu erstellen und mithilfe der Reflexion die entsprechenden LINQ-Methode aufzurufen, während die Ausdrucksbaumstruktur übergeben wird.

Sie können auch die Funktionalität der LINQ-Methode duplizieren, indem Sie die gesamte Struktur in einem MethodCallExpression-Objekt umschließen, das einen Aufruf der LINQ-Methode darstellt:

IQueryable TextFilter_Untyped(IQueryable source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }
    Type elementType = source.ElementType;

    // The logic for building the ParameterExpression and the LambdaExpression's body is the same as in the previous example,
    // but has been refactored into the constructBody function.
    (Expression? body, ParameterExpression? prm) = constructBody(elementType, term);
    if (body is null) { return source; }

    Expression filteredTree = Call(
        typeof(Queryable),
        "Where",
        [elementType],
        source.Expression,
        Lambda(body, prm!)
    );

    return source.Provider.CreateQuery(filteredTree);
}

In diesem Fall ist zur Kompilierzeit kein generischer T-Platzhalter verfügbar. Aus diesem Grund verwenden Sie die Lambda-Überladung, die keine Typinformationen zur Kompilierzeit benötigt und LambdaExpression anstelle eines Expression<TDelegate>-Objekts erzeugt.

Die dynamische LINQ-Bibliothek

Das Erstellen von Ausdrucksbaumstrukturen mit Factorymethoden ist relativ komplex. Es ist einfacher, Zeichenfolgen zu verfassen. Die dynamische LINQ-Bibliothek macht eine Reihe von Erweiterungsmethoden für IQueryable verfügbar, die den LINQ-Standardmethoden in Queryable entsprechen und die Zeichenfolgen in einer besonderen Syntax anstelle von Ausdrucksbaumstrukturen akzeptieren. Die Bibliothek generiert die entsprechende Ausdrucksbaumstruktur aus der Zeichenfolge und kann die resultierende übersetzte Schnittstelle IQueryable zurückgeben.

Das vorherige Beispiel könnte z. B. wie folgt umgeschrieben werden:

// using System.Linq.Dynamic.Core

IQueryable TextFilter_Strings(IQueryable source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }

    var elementType = source.ElementType;

    // Get all the string property names on this specific type.
    var stringProperties =
        elementType.GetProperties()
            .Where(x => x.PropertyType == typeof(string))
            .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Build the string expression
    string filterExpr = string.Join(
        " || ",
        stringProperties.Select(prp => $"{prp.Name}.Contains(@0)")
    );

    return source.Where(filterExpr, term);
}