Ich möchte mit einer Beschreibung des Problems beginnen, auf das ich gestoßen bin. Es gibt Entitäten in der Datenbank, die als Tabellen auf der Benutzeroberfläche angezeigt werden müssen. Für den Zugriff auf die Datenbank wird das Entity Framework verwendet. Für diese Tabellenspalten gibt es Filter.
Es ist notwendig, einen Code zu schreiben, um Entitäten nach Parametern zu filtern.
Beispielsweise gibt es zwei Entitäten:Benutzer und 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; } }
Angenommen, wir müssen Benutzer und Produkte nach Namen filtern. Wir erstellen Methoden, um jede Entität zu filtern.
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)); }
Wie Sie sehen können, sind diese beiden Methoden fast identisch und unterscheiden sich nur in der Entitätseigenschaft, nach der die Daten gefiltert werden.
Es kann eine Herausforderung sein, wenn wir Dutzende von Entitäten mit Dutzenden von Feldern haben, die gefiltert werden müssen. Die Komplexität liegt in der Codeunterstützung, dem gedankenlosen Kopieren und als Ergebnis einer langsamen Entwicklung und einer hohen Fehlerwahrscheinlichkeit.
Fowler paraphrasierend, es fängt an zu riechen. Ich würde gerne etwas Standardmäßiges schreiben, anstatt Codeduplizierung. Zum Beispiel:
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)); }
Leider, wenn wir versuchen zu filtern:
public void TestFilter() { using (var context = new Context()) { var filteredProducts = FilterProductsByName(context.Products, "name").ToArray(); } }
Wir erhalten die Fehlermeldung «Test method ExpressionTests.ExpressionTest.TestFilter threw the exception:
System.NotSupportedException :Der LINQ-Ausdrucksknotentyp ‘Invoke’ wird nicht unterstützt in LINQ to Entities.
Ausdrücke
Sehen wir uns an, was schief gelaufen ist.
Die Where-Methode akzeptiert einen Parameter des Typs Expression
Der Ausdruck beschreibt einen Syntaxbaum. Um besser zu verstehen, wie sie strukturiert sind, betrachten Sie den Ausdruck, der prüft, ob ein Name einer Zeile entspricht.
Expression<Func<Product, bool>> expected = product => product.Name == "target";
Beim Debuggen können wir die Struktur dieses Ausdrucks sehen (Schlüsseleigenschaften sind rot markiert).
Wir haben den folgenden Baum:
Wenn wir einen Delegaten als Parameter übergeben, wird ein anderer Baum generiert, der die Invoke-Methode für den (Delegaten-)Parameter aufruft, anstatt die Entitätseigenschaft aufzurufen.
Wenn Linq versucht, anhand dieses Baums eine SQL-Abfrage zu erstellen, weiß es nicht, wie es die Invoke-Methode interpretieren soll, und löst NotSupportedException aus.
Unsere Aufgabe ist es also, den Cast auf die Entity-Property (den rot markierten Baumteil) durch den Ausdruck zu ersetzen, der über diesen Parameter übergeben wird.
Versuchen wir:
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"
Jetzt können wir den Fehler «Methodenname erwartet» in der Kompilierungsphase sehen.
Das Problem ist, dass ein Ausdruck eine Klasse ist, die Knoten einer Syntaxstruktur darstellt, und nicht der Delegat, und dass er nicht direkt aufgerufen werden kann. Die Hauptaufgabe besteht nun darin, einen Weg zu finden, einen Ausdruck zu erstellen, der ihm einen weiteren Parameter übergibt.
Der Besucher
Nach einer kurzen Google-Suche fand ich eine Lösung für das ähnliche Problem bei StackOverflow.
Um mit Ausdrücken zu arbeiten, gibt es die ExpressionVisitor-Klasse, die das Visitor-Muster verwendet. Es wurde entwickelt, um alle Knoten des Ausdrucksbaums in der Reihenfolge der Analyse des Syntaxbaums zu durchlaufen, und ermöglicht es, sie zu ändern oder stattdessen einen anderen Knoten zurückzugeben. Wenn weder der Knoten noch seine untergeordneten Knoten geändert werden, wird der ursprüngliche Ausdruck zurückgegeben.
Beim Erben von der Klasse ExpressionVisitor können wir jeden Baumknoten durch den Ausdruck ersetzen, den wir über den Parameter übergeben. Daher müssen wir ein Node-Label, das wir durch einen Parameter ersetzen, in den Baum einfügen. Schreiben Sie dazu eine Erweiterungsmethode, die den Aufruf des Ausdrucks simuliert und eine Markierung darstellt.
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."); } }
Jetzt können wir einen Ausdruck durch einen anderen ersetzen
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";
Es ist notwendig, einen Besucher zu schreiben, der die Call-Methode durch ihren Parameter im Ausdrucksbaum ersetzt:
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; } }
Wir können unseren Marker ersetzen:
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();
Beim Debuggen können wir sehen, dass der Ausdruck nicht unseren Erwartungen entspricht. Der Filter enthält noch die Invoke-Methode.
Tatsache ist, dass die Ausdrücke parameterGetter und finalFilter zwei unterschiedliche Argumente verwenden. Daher müssen wir ein Argument in parameterGetter durch das Argument in finalFilter ersetzen. Dazu erstellen wir einen weiteren Besucher:
Das Ergebnis lautet wie folgt:
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; } }
Jetzt funktioniert alles wie es soll und wir können endlich unsere Filtermethode schreiben
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()); }
Schlussfolgerung
Der Ansatz mit der Ausdrucksersetzung kann nicht nur zum Filtern, sondern auch zum Sortieren und für beliebige Abfragen an die Datenbank verwendet werden.
Außerdem ermöglicht diese Methode das Speichern von Ausdrücken zusammen mit der Geschäftslogik getrennt von den Abfragen an die Datenbank.
Sie können sich den Code auf GitHub ansehen.
Dieser Artikel basiert auf einer StackOverflow-Antwort.