Jag skulle vilja börja med en beskrivning av problemet som jag stötte på. Det finns enheter i databasen som måste visas som tabeller i användargränssnittet. Entity Framework används för att komma åt databasen. Det finns filter för dessa tabellkolumner.
Det är nödvändigt att skriva en kod för att filtrera enheter efter parametrar.
Det finns till exempel två enheter:Användare och 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; } }
Anta att vi behöver filtrera användare och produkter efter namn. Vi skapar metoder för att filtrera varje enhet.
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)); }
Som du kan se är dessa två metoder nästan identiska och skiljer sig bara åt i entitetsegenskapen, genom vilken den filtrerar data.
Det kan vara en utmaning om vi har dussintals enheter med dussintals fält som kräver filtrering. Komplexiteten ligger i kodstöd, tanklös kopiering och som ett resultat långsam utveckling och hög sannolikhet för fel.
Om man parafraserar Fowler, börjar det lukta. Jag skulle vilja skriva något standard istället för kodduplicering. Till exempel:
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)); }
Tyvärr, om vi försöker filtrera:
public void TestFilter() { using (var context = new Context()) { var filteredProducts = FilterProductsByName(context.Products, "name").ToArray(); } }
Vi kommer att få felet «Testmetod ExpressionTests.ExpressionTest.TestFilter gjorde undantaget:
System.NotSupportedException :LINQ-uttrycksnodtypen 'Anropa' stöds inte i LINQ till Entities.
Uttryck
Låt oss kolla vad som gick fel.
Where-metoden accepterar en parameter av typen Expression
Uttrycket beskriver ett syntaxträd. För att bättre förstå hur de är strukturerade, överväg uttrycket, som kontrollerar att ett namn är lika med en rad.
Expression<Func<Product, bool>> expected = product => product.Name == "target";
Vid felsökning kan vi se strukturen för detta uttryck (nyckelegenskaper är markerade med rött).
Vi har följande träd:
När vi skickar en delegat som en parameter genereras ett annat träd som anropar Invoke-metoden på parametern (delegat) istället för att anropa entitetsegenskapen.
När Linq försöker bygga en SQL-fråga med detta träd vet den inte hur den ska tolka Invoke-metoden och skickar NotSupportedException.
Därför är vår uppgift att ersätta casten till entitetsegenskapen (träddelen markerad med rött) med uttrycket som skickas via denna parameter.
Låt oss försöka:
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"
Nu kan vi se felet «Metodnamn förväntat» vid kompileringsstadiet.
Problemet är att ett uttryck är en klass som representerar noder i ett syntaxträd snarare än delegaten och det kan inte anropas direkt. Nu är huvuduppgiften att hitta ett sätt att skapa ett uttryck som skickar en annan parameter till det.
Besökaren
Efter en kort sökning på Google hittade jag en lösning på liknande problem på StackOverflow.
För att arbeta med uttryck finns ExpressionVisitor-klassen, som använder sig av Visitor-mönstret. Den är utformad för att korsa alla noder i uttrycksträdet i ordningen för att analysera syntaxträdet och tillåter att modifiera dem eller returnera en annan nod istället. Om varken noden eller dess underordnade noder ändras, returneras det ursprungliga uttrycket.
När vi ärver från klassen ExpressionVisitor kan vi ersätta vilken trädnod som helst med uttrycket som vi skickar via parametern. Därför måste vi sätta någon nodetikett, som vi kommer att ersätta med en parameter, i trädet. För att göra detta, skriv en förlängningsmetod som simulerar anropet av uttrycket och kommer att vara en markör.
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."); } }
Nu kan vi ersätta ett uttryck med ett annat
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";
Det är nödvändigt att skriva en besökare, som kommer att ersätta Call-metoden med dess parameter i uttrycksträdet:
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; } }
Vi kan byta ut vår markör:
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();
Vid felsökning kan vi se att uttrycket inte är vad vi förväntade oss. Filtret innehåller fortfarande metoden Invoke.
Faktum är att uttrycken parameterGetter och finalFilter använder två olika argument. Därför måste vi ersätta ett argument i parameterGetter med argumentet i finalFilter. För att göra detta skapar vi en annan besökare:
Resultatet är följande:
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; } }
Nu fungerar allt som det ska och vi kan äntligen skriva vår filtreringsmetod
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()); }
Slutsats
Tillvägagångssättet med uttrycksersättningen kan användas inte bara för filtrering utan också för sortering och eventuella frågor till databasen.
Dessutom tillåter den här metoden att lagra uttryck tillsammans med affärslogik separat från frågorna till databasen.
Du kan titta på koden på GitHub.
Den här artikeln är baserad på ett StackOverflow-svar.