Me gustaría comenzar con una descripción del problema que encontré. Hay entidades en la base de datos que deben mostrarse como tablas en la interfaz de usuario. El Entity Framework se utiliza para acceder a la base de datos. Hay filtros para estas columnas de la tabla.
Es necesario escribir un código para filtrar entidades por parámetros.
Por ejemplo, hay dos entidades:Usuario y Producto.
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; } }
Supongamos que necesitamos filtrar usuarios y productos por nombre. Creamos métodos para filtrar cada entidad.
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)); }
Como puede ver, estos dos métodos son casi idénticos y difieren solo en la propiedad de la entidad, por la cual filtra los datos.
Puede ser un desafío si tenemos docenas de entidades con docenas de campos que requieren filtrado. La complejidad está en el soporte del código, la copia irreflexiva y, como resultado, el desarrollo lento y la alta probabilidad de error.
Parafraseando a Fowler, empieza a oler mal. Me gustaría escribir algo estándar en lugar de duplicación de código. Por ejemplo:
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)); }
Desafortunadamente, si intentamos filtrar:
public void TestFilter() { using (var context = new Context()) { var filteredProducts = FilterProductsByName(context.Products, "name").ToArray(); } }
Obtendremos el error «El método de prueba ExpressionTests.ExpressionTest.TestFilter lanzó la excepción:
System.NotSupportedException :El tipo de nodo de expresión LINQ 'Invocar' no es compatible en LINQ a Entidades.
Expresiones
Veamos qué salió mal.
El método Where acepta un parámetro del tipo Expression
La expresión describe un árbol de sintaxis. Para entender mejor cómo están estructurados, considere la expresión, que comprueba que un nombre es igual a una fila.
Expression<Func<Product, bool>> expected = product => product.Name == "target";
Al depurar, podemos ver la estructura de esta expresión (las propiedades clave están marcadas en rojo).
Tenemos el siguiente árbol:
Cuando pasamos un delegado como parámetro, se genera un árbol diferente, que llama al método Invoke en el parámetro (delegado) en lugar de invocar la propiedad de la entidad.
Cuando Linq intenta crear una consulta SQL con este árbol, no sabe cómo interpretar el método Invoke y lanza NotSupportedException.
Por lo tanto, nuestra tarea es reemplazar la conversión a la propiedad de la entidad (la parte del árbol marcada en rojo) con la expresión que se pasa a través de este parámetro.
Probemos:
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"
Ahora, podemos ver el error «Nombre del método esperado» en la etapa de compilación.
El problema es que una expresión es una clase que representa nodos de un árbol de sintaxis, en lugar del delegado y no se puede llamar directamente. Ahora, la tarea principal es encontrar una forma de crear una expresión pasándole otro parámetro.
El Visitante
Después de una breve búsqueda en Google, encontré una solución a un problema similar en StackOverflow.
Para trabajar con expresiones, existe la clase ExpressionVisitor, que utiliza el patrón Visitor. Está diseñado para atravesar todos los nodos del árbol de expresión en el orden de analizar el árbol de sintaxis y permite modificarlos o devolver otro nodo en su lugar. Si no se modifican ni el nodo ni sus nodos secundarios, se devuelve la expresión original.
Al heredar de la clase ExpressionVisitor, podemos reemplazar cualquier nodo del árbol con la expresión, que pasamos a través del parámetro. Por lo tanto, debemos colocar alguna etiqueta de nodo, que reemplazaremos con un parámetro, en el árbol. Para ello, escribe un método de extensión que simulará la llamada de la expresión y será un marcador.
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."); } }
Ahora, podemos reemplazar una expresión con otra
Expression<Func<Product, string>> propertyGetter = product => product.Name; Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";
Es necesario escribir un visitante, que reemplazará el método Call con su parámetro en el árbol de expresión:
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; } }
Podemos reemplazar nuestro marcador:
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();
En la depuración, podemos ver que la expresión no es lo que esperábamos. El filtro aún contiene el método Invocar.
El hecho es que las expresiones ParameterGetter y finalFilter usan dos argumentos diferentes. Por lo tanto, necesitamos reemplazar un argumento en parámetroGetter con el argumento en finalFilter. Para ello, creamos otro visitante:
El resultado es el siguiente:
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; } }
Ahora, todo funciona como debería y, finalmente, podemos escribir nuestro método de filtración
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()); }
Conclusión
El enfoque con el reemplazo de expresión se puede usar no solo para filtrar sino también para ordenar y cualquier consulta a la base de datos.
Además, este método permite almacenar expresiones junto con la lógica comercial por separado de las consultas a la base de datos.
Puedes mirar el código en GitHub.
Este artículo se basa en una respuesta de StackOverflow.