miércoles, 4 de julio de 2012

Entity Framework y el borrado de entidades (II) - usando lambda y árboles de expresión

Como ya explique en el artículo anterior la forma más natural de borrar un registro es a través de su clave primaria, aunque eso no siempre tiene porque ser así. Un par de ejemplos sobre esto puede ser
  • Borrar aquellos registros que tengan más de un año de antigüedad. Práctico en tablas de logs.
  • Borrar aquellos registros que tengan un estado determinado.
  • O simplemente querer borrar registros en base a una clave ajena, como por ejemplo, borrar todas los lineas de una factura.
En todos estos casos la solución planteada en el artículo anterior no nos valdría ya que asumimos que tan sólo vamos a borrar un registro.

Borrado de varias entidades
Basándonos en este ejemplo
public class Bar
{
  public int IdBar { get; set; }
  public string Description { get; set; }
  public int IdState { get; set; }
}
y queriendo borrar aquellos registros que estén en el estado 1 podríamos hacer lo siguiente
using (DataContext context = new DataContext())
{
  var list = context.Bar.Where(m => m.IdState == 1);
  foreach (Bar bar in list)
    context.Bar.Remove(bar);
  context.SaveChanges();
}
Si analizamos el código anterior veremos que para borrar n elementos, debemos hacer n + 1 accesos a la base de datos. Lanzaremos n consultas de borrado, y necesitaremos una más para recuperar los elementos a borrar.

Como últimamente me he vuelto un inconformista esta solución no me gusta porque para borrar 1000 elementos necesito hacer 1001 acceso a base de datos y eso me parece demasiada carga para un servidor, que en mi caso, ya anda demasiado justo de recursos.

La primera solución que se me ocurrió fue usar un método de este estilo
using (DataContext context = new DataContext())
{
  context.Database.ExecuteSqlCommand("DELETE FROM Bar WHERE IdState = @IdState", new 
object[] { new System.Data.SqlClient.SqlParameter("IdState", 1) });
}
Esta solución es bastante buena aunque me deja atado a una tabla y a un filtro en concreto, lo cual me obliga a estar estableciendo métodos por cada tabla donde desee borrar un conjunto de datos y si uso varios filtros a estar usando parámetros opcionales (que suelen afear el código bastante).

Para mi lo ideal sería en un sólo método poder resolver esta situación, y tras investigar un poco se puede hacer con algo de código extra. Dado que estoy usando Code First he creado un método Delete en el contexto con el siguiente aspecto
public void Delete<T>(Expression<Func<T, bool>> condition)
{
  string where = generateWhere(condition.Body);
  string query = String.Format("DELETE FROM {0} WHERE {1}", typeof(T).Name, where);
  ((IObjectContextAdapter)this).ObjectContext.ExecuteStoreCommand(query);
}
De esta manera puedo pasar que entidad estoy intentado borrar y los filtros que voy a usar en el borrado. Antes de continuar me gustaría aclarar que transformar un árbol de expresion a una condición SQL no es una tarea fácil y esta llena de pequeño matices que puede que hagan que no sea rentable realizar el esfuerzo. En mi caso las condiciones de borrado son muy simples y la mejora de rendimiento (que veremos al final) justificaban el pequeño esfuerzo que ha llevado el desarrollo de este método. Pero ojo, hacer un LINQ Provider no es una tarea que se pueda considerar trivial.

Bien, con esto podríamos llamar a nuestro método de borrado de la siguiente manera
using (DataContext context = new DataContext())
{
  context.Delete<Bar>(m => m.IdState == 1);
}
Si analizamos nuestra Expression de entrada vemos como tiene una propiedad Body donde está la/s condiciones que hemos pasado y que tenemos que transformar a una condición Where de SQL. En esta propiedad tenemos tanto la parte izquierda como la parte derecha de la condición, así como el operador que se está usando. En el método generateWhere analizaremos todas estas partes para devolver un Where válido.
private string generateWhere(dynamic operation)
{
  string left = generateToken(ExpressionTypeEnum.Left, operation.Left);
  string right = generateToken(ExpressionTypeEnum.Right, operation.Right);

  // TODO: Completar operaciones (por ahora con estas es suficiente)
  var ops = new Dictionary();
  ops.Add(ExpressionType.Equal, "=");
  ops.Add(ExpressionType.GreaterThan, ">");
  ops.Add(ExpressionType.GreaterThanOrEqual, ">=");
  ops.Add(ExpressionType.LessThan, "<");
  ops.Add(ExpressionType.LessThanOrEqual, "<=");

  ops.Add(ExpressionType.And, "AND");
  ops.Add(ExpressionType.AndAlso, "AND");
  ops.Add(ExpressionType.Or, "OR");
  ops.Add(ExpressionType.OrElse, "OR");

  return string.Format("{0} {1} {2}", left, ops[operation.NodeType], right);
}
En mi caso estoy tomando como base que la Expression es de tipo BinaryExpression aunque más exactamente es de tipo LogicalBinaryExpression (esto lo podemos comprobar en tiempo de ejecución). En otras expresiones este análisis de la expresión fallaría por no tener alomejor una propiedad Right.

Vemos que tanto para la parte izquierda como para la parte derecha llamamos al método generateToken que se encarga de generarnos una cadena con el valor contenido en esa parte, e incluso, de llamar recursivamente al método generateWhere en caso que estemos combinado filtros de esta manera
using (DataContext context = new DataContext())
{
  context.Delete<Bar>(m => m.IdState == 1 || m.IdState == 2);
}
El método generateToken tendría el siguiente aspecto
private string generateToken(ExpressionTypeEnum type, dynamic operation)
{
  string token = string.Empty;
  if (operation is BinaryExpression)
    token = generateWhere(operation);
  else
    token = (type == ExpressionTypeEnum.Left ? operation.Member.Name : operation.Value.ToString());
   
  return token;
}
Como único detalle hay que tener en cuenta que dependiendo de que parte de la expresión estemos analizando extraeremos el token de una u otra propiedad.

Con esto ya tenemos nuestro pequeño "parser" listo para ser usado en un entorno controlado y con condiciones simples. He detectados otras expresiones que poco a poco iré añadiendo. Estas expresiones son
  • UnaryExpression. Para filtros de tipo m => !m.IsActive. Donde IsActive es de tipo boolean.
  • PropertyExpression: Para filtros de tipo m=> m.IsActive. Donde IsActive es de tipo boolean.
  • MethodBinaryExpression: Para filtros de tipo m=>m.Date < DateTime.Now. Donde como es lógico Date es de tipo DateTime.
Para ver la mejora de rendimiento he realizado un borrado iterativo y un borrado con esté método usando el siguiente código
Iterativo
using (DataContext context = new DataContext())
{
  var list = context.Bar.Where(m => m.IdState == 1);
  foreach (Bar bar in list)
    context.Bar.Remove(bar);
  context.SaveChanges(); 
}
Directo
using (DataContext context = new DataContext())
{
  context.Delete<Bar>(m => m.IdState == 1);
}

He lanzado estos métodos borrado 10, 100, 1000 y 10000 elementos. Cada bloque lo he lanzado tres veces y he calculado la media para dar la duración de la duración. Con estas condiciones estos son los datos que he obtenido

Registros a borrar - Tiempo de ejecución en ms 

Conclusiones
Como vemos en la tabla para borrados de más de 1000 registros el borrado iterativo se puede vuelve un poco pesado, ya que nos lleva casi 4 segundos ejecutarlo (producto de tener que lanzar 1001 consultas a la base de datos), mientra que nuestro borrado directo no llega a los 12 ms. En condiciones más extremas (10.000 registros) el borrado iterativo llega a casi 2 minutos de ejecución mientras que el directo no nos pasa de un cuarto de segundo. Interesante, ¿verdad?

En la mayoría de los escenarios no merece la pena complicarnos la vida tanto ya que tan solo borraremos un par de registros y podemos optar por el método iterativo, ya que como vemos entre ambos métodos para 10 registros casi no tenemos diferencias de tiempos. Para borrados de más registros siempre podemos optar procedimientos almacenados o lanzar directamente nosotros la consultas desde nuestro programa, pero si queremos tener a nuestra disposición mucho filtros para borrar está opción nos puede valer.

Eso si, como ejercicio de programación ha estado realmente bien y me he permitido ver por encima como funcionan los árboles de expresión.
Happy coding!

No hay comentarios:

Publicar un comentario