Ik zou willen beginnen met een beschrijving van het probleem dat ik tegenkwam. Er zijn entiteiten in de database die als tabellen in de gebruikersinterface moeten worden weergegeven. Het Entity Framework wordt gebruikt om toegang te krijgen tot de database. Er zijn filters voor deze tabelkolommen.
Het is noodzakelijk om een code te schrijven om entiteiten op parameters te filteren.
Er zijn bijvoorbeeld twee entiteiten:Gebruiker en Product.
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; } }
Stel dat we gebruikers en producten op naam moeten filteren. We creëren methoden om elke entiteit te filteren.
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)); }
Zoals u kunt zien, zijn deze twee methoden bijna identiek en verschillen ze alleen in de entiteitseigenschap, waarmee de gegevens worden gefilterd.
Het kan een uitdaging zijn als we tientallen entiteiten hebben met tientallen velden die moeten worden gefilterd. Complexiteit zit in code-ondersteuning, ondoordacht kopiëren, en als gevolg daarvan trage ontwikkeling en grote kans op fouten.
Fowler parafraserend:het begint te ruiken. Ik zou graag iets standaards willen schrijven in plaats van codeduplicatie. Bijvoorbeeld:
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)); }
Helaas, als we proberen te filteren:
public void TestFilter() { using (var context = new Context()) { var filteredProducts = FilterProductsByName(context.Products, "name").ToArray(); } }
We krijgen de foutmelding «Testmethode ExpressionTests.ExpressionTest.TestFilter gooide de uitzondering:
System.NotSupportedException :Het LINQ-expressieknooppunttype 'Invoke' wordt niet ondersteund in LINQ naar entiteiten.
Uitdrukkingen
Laten we eens kijken wat er mis is gegaan.
De Where-methode accepteert een parameter van het type Expression
De expressie beschrijft een syntaxisboom. Om beter te begrijpen hoe ze zijn gestructureerd, kunt u de uitdrukking overwegen, die controleert of een naam gelijk is aan een rij.
Expression<Func<Product, bool>> expected = product => product.Name == "target";
Bij het debuggen kunnen we de structuur van deze uitdrukking zien (sleuteleigenschappen zijn rood gemarkeerd).
We hebben de volgende boom:
Wanneer we een gedelegeerde als parameter doorgeven, wordt een andere boom gegenereerd, die de Invoke-methode op de (gedelegeerde) parameter aanroept in plaats van de eigenschap entiteit aan te roepen.
Wanneer Linq een SQL-query probeert te bouwen met deze boom, weet het niet hoe het de Invoke-methode moet interpreteren en wordt NotSupportedException gegenereerd.
Het is dus onze taak om de cast naar de entiteitseigenschap (het boomgedeelte dat in het rood is gemarkeerd) te vervangen door de expressie die via deze parameter wordt doorgegeven.
Laten we proberen:
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"
Nu kunnen we de fout «Methodenaam verwacht» zien in de compilatiefase.
Het probleem is dat een expressie een klasse is die knooppunten van een syntaxisboom vertegenwoordigt, in plaats van de gedelegeerde, en dat deze niet rechtstreeks kan worden aangeroepen. De belangrijkste taak is nu om een manier te vinden om een uitdrukking te maken door er een andere parameter aan door te geven.
De Bezoeker
Na een korte Google-zoekopdracht vond ik een oplossing voor het vergelijkbare probleem bij StackOverflow.
Om met expressies te werken, is er de klasse ExpressionVisitor, die het patroon Visitor gebruikt. Het is ontworpen om alle knooppunten van de expressiestructuur te doorlopen in de volgorde van het ontleden van de syntaxisstructuur en maakt het mogelijk ze te wijzigen of in plaats daarvan een ander knooppunt terug te geven. Als noch het knooppunt, noch de onderliggende knooppunten worden gewijzigd, wordt de oorspronkelijke uitdrukking geretourneerd.
Wanneer we overerven van de klasse ExpressionVisitor, kunnen we elk boomknooppunt vervangen door de expressie, die we doorgeven via de parameter. We moeten dus een knooppuntlabel in de boomstructuur plaatsen, dat we zullen vervangen door een parameter. Schrijf hiervoor een extensiemethode die de aanroep van de uitdrukking simuleert en een markering zal zijn.
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 kunnen we de ene uitdrukking vervangen door een andere
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";
Het is noodzakelijk om een bezoeker te schrijven, die de methode Call zal vervangen door zijn parameter in de expressiestructuur:
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; } }
We kunnen onze marker vervangen:
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();
Bij het debuggen kunnen we zien dat de expressie niet is wat we hadden verwacht. Het filter bevat nog steeds de Invoke-methode.
Het feit is dat de expressies parameterGetter en finalFilter twee verschillende argumenten gebruiken. We moeten dus een argument in parameterGetter vervangen door het argument in finalFilter. Om dit te doen, maken we een andere bezoeker aan:
Het resultaat is als volgt:
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 werkt alles zoals het hoort en kunnen we eindelijk onze filtratiemethode schrijven
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()); }
Conclusie
De benadering met de vervanging van de uitdrukking kan niet alleen worden gebruikt voor filteren, maar ook voor sorteren en elke query naar de database.
Deze methode maakt het ook mogelijk om expressies samen met bedrijfslogica apart van de query's naar de database op te slaan.
Je kunt de code bekijken op GitHub.
Dit artikel is gebaseerd op een StackOverflow-antwoord.