Sentencias en LINQ (III): Agrupaciones (group by)


Tras saber cómo filtrar elementos, es buen momento para aprender a agruparlos. Agrupar elementos, como su propio nombre indica, es concentrar los datos de un registro a partir de una característica común. Por ejemplo, saber los pedidos que se corresponden a cada uno de los clientes.

Su sintaxis es la siguiente:


            var agrupacion = from p in DataLists.ListaPedidos
                             group p by p.IdCliente into grupo
                             select grupo;

Lo cual nos devolverá un listado de agrupaciones (objeto que implementa la interfaz IGrouping<tipoClave, tipoObjetoAgrupado>) compuesto por dos elementos principales:

  • Key: contiene la clave de la agrupación, es decir, el campo por el cual se está agrupando. En este caso se trataría del valor de p.IdCliente.
  • <Implícito>: el objeto en sí también es un listado compuesto por los objetos sobre los que itera la cláusula from, es decir, contenidos en DataList.ListaPedidos. En este caso sería un listado de objetos de tipo Pedido. Dado que están agrupados, el objeto grupo sólo contendrá aquellos objetos de la clase Pedido cuyo valor Pedido.IdCliente sea el mismo en todos los elementos de la lista.

Así, podríamos anidar perfectamente dos bucles foreach para recorrer con el primero la lista de claves por las que se agrupa (grupo.Key) y utilizar el bucle interno para recorrer la lista de objetos que se ajustan al criterio de agrupación:


            foreach (var grupo in agrupacion)
            {
                Console.WriteLine("ID Cliente: " + grupo.Key);
                foreach (var objetoAgrupado in grupo)
                    Console.Write("\t\tPedido nº " + objetoAgrupado.Id + ": " + objetoAgrupado.FechaPedido + "]" + Environment.NewLine);
            }

 

El resultado, el siguiente:

Agrupar por más de un campo

Por supuesto, esto puede tener utilidades como la de contabilizar el número de pedidos que ha realizado un cliente. Hagamos una pequeña combinación de join y groupby para obtener esta información:


            var agrupacion = from p in DataLists.ListaPedidos
                             join c in DataLists.ListaClientes on p.IdCliente equals c.Id
                             group p by new { p.IdCliente, c.Nombre } into grupo
                             select grupo;

            foreach (var grupo in agrupacion)
            {
                Console.WriteLine("Nombre Cliente: " + grupo.Key.Nombre + " (ID: " + grupo.Key.IdCliente + ")");
                foreach (var objetoAgrupado in grupo)
                    Console.Write("\tPedido nº " + objetoAgrupado.Id + ": " + objetoAgrupado.FechaPedido + "]" + Environment.NewLine);
            }

Como podemos observar, la clave no tiene por qué ser un valor entero. De hecho, no tiene ni siquiera por qué ser un único elemento: en este caso realizamos una agrupación por ID de cliente y por nombre, pudiendo acceder a esta información dentro de la propia clave.

Debemos intentar «proyectar» nuestra forma de pensar en SQL hacia LINQ. Recordemos que tanto un lenguaje como otro se basan en aritmética relacional, por lo que lo que sea válido para un lenguaje, obligatoriamente ha de resultarlo para el otro. Si sabemos que la siguiente consulta en SQL nos devolverá los pedidos de un usuario en una base de datos:


        select c.Nombre, p.IdCliente, count(p.Id)
            from Pedidos p
            inner join Clientes c on c.Id = p.IdCliente
            group by p.IdCliente, c.Nombre

Debemos intentar tener claro el concepto de que la clave de agrupación son los elementos por los cuales queremos agrupar. Y así deberá ser también en LINQ. Por lo tanto, la sentencia SQL


            group by p.IdCliente, c.Nombre

Se corresponderá en LINQ con


            group p by new { p.IdCliente, c.Nombre } into grupo

Como podemos comprobar, la diferencia es mínima. Y el resultado, como podemos observar, será el siguiente:

Anidar agrupaciones

Gracias a la versatilidad de LINQ, podemos ir todavía un poco más allá y anidar las agrupaciones, de modo que obtengamos todas las líneas de pedido que pertenecen a un pedido y a su vez, todos los pedidos que pertenecen a un cliente.

Aprender este concepto no es difícil, pero anidar código siempre contribuye a la confusión. Por eso iremos poco a poco indicando lo que deberíamos hacer en cada caso. Comenzaremos con la consulta anterior. Ésta consulta nos devolverá un único objeto que implementa la interfaz IGrouping<TKey, TElement>, siendo:

  • TKey: clave del grupo, compuesto por el tipo anónimo {int, string} (Id de cliente y nombre de cliente)
  • TElement: tipo de los valores almacenados, en este caso, Pedido.

Por lo tanto, el siguiente fragmento de código generará un grupo con una clave de dos elementos y una lista de objetos de la clase Pedido:


            var consultaClientes = from pedido in DataLists.ListaPedidos
                           join cliente in DataLists.ListaClientes
                                on pedido.IdCliente equals cliente.Id
                           group pedido by new
                           {
                               cliente.Id,
                               cliente.Nombre
                           } into pedidosPorCliente
                           select pedidosPorCliente;    // Key = {Id de cliente, Nombre de cliente}
                                                        // Grupo = List

También queremos obtener agrupadas todas las líneas de pedido asociadas a un único pedido, agrupando por el Id del pedido y su fecha de realización. Su estructura será similar a la que acabamos de generar, salvo que en lugar de usar las entidades Cliente y Pedido usaremos las entidades Pedido y LineaPedido. De momento, y para aclararnos, crearemos una nueva consulta LINQ que sea independiente a la anterior.


            var consultaPedidos = from lineaPedido in DataLists.ListaLineasPedido
                            join pedido in DataLists.ListaPedidos
                                on lineaPedido.IdPedido equals pedido.Id
                            group lineaPedido by new
                            {
                                pedido.Id,
                                pedido.FechaPedido
                            } into lineasPorPedido      // Key = {Id de pedido, Fecha de realización del pedido}
                            select lineasPorPedido;     // Grupo = List

La tercera consulta implicada ya no precisa de agrupaciones. Se encargará de realizar un join entre Producto y LineaPedido para poder mostrar en un solo registro la información del producto asociado a la línea, como su nombre y precio. Además, podremos calcular de forma dinámica el precio total multiplicando la cantidad de productos indicados en la línea de producto por el valor unitario de cada producto. Nuevamente, no se trata de una consulta muy compleja:


            var lineaProducto = from linea in DataLists.ListaLineasPedido
                                join producto in DataLists.ListaProductos
                                    on linea.IdProducto equals producto.Id
                                select new
                                {
                                    IdLineaPedido = linea.Id,
                                    Nombre = producto.Descripcion,
                                    Cantidad = linea.Cantidad,
                                    PrecioUnitario = producto.Precio,
                                    PrecioTotal = (producto.Precio * linea.Cantidad)
                                };

Hasta aquí no ha habido mucha complicación: hemos realizado tres consultas que se encuentran conceptualmente relacionadas, pero no hemos establecido una relación entre ellas. Es hora de unirlas en una única consulta. ¿Cómo realizamos esto? Recordemos que lo que realiza select es la sentencia encargada de efectuar las proyecciones. Por lo tanto, si en lugar de indicarle que nos devuelva un objeto de la serie que estamos recorriendo le decimos que nos devuelva otra cosa, creará lo que le digamos. Y esa «cosa» puede estar relacionada con la colección recorrida… y también ser una nueva consulta.

Por tanto, queda bastante claro que es en a continuación de select donde debemos conectar nuestras consultas. Comenzaremos uniendo las dos primeras, haciendo que en global, la sentencia nos devuelva una agrupación de agrupaciones de líneas de pedido en lugar de que nos devuelva una simple agrupación de pedidos.


            var consulta = from pedido in DataLists.ListaPedidos
                                        join cliente in DataLists.ListaClientes
                                             on pedido.IdCliente equals cliente.Id
                                        group pedido by new
                                        {
                                            cliente.Id,
                                            cliente.Nombre
                                        } into pedidosPorCliente
                                        select
                                             from lineaPedido in DataLists.ListaLineasPedido
                                             join pedido in DataLists.ListaPedidos
                                                 on lineaPedido.IdPedido equals pedido.Id
                                             group lineaPedido by new
                                             {
                                                 pedido.Id,
                                                 pedido.FechaPedido
                                             } into lineasPorPedido
                                             select lineasPorPedido;

Como podemos observar, hemos conectado vilmente el contenido de la segunda consulta y lo hemos concatenado a continuación de la primera. Sin embargo, lo que nos devolverá esta consulta será algo con la siguiente estructura:

  • IGroupable<{int, string}, IGroupable<{int, DateTime}, LineaPedido>

Es decir, un grupo con clave (int, string) que contiene una lista de grupos (int, DateTime) que contiene una lista de objetos de la clase LineaPedido. Un poco lioso, ¿verdad? Tranquilos, lo arreglaremos cambiando el valor que devuelve la primera select. En lugar de que devuelva un grupo en bruto, ¿qué tal si hacemos que devuelva un tipo anónimo que sea un poco más legible? Por ejemplo, haremos que el tipo anónimo contenga el ID del cliente, el nombre del cliente y, ya al final, el listado de agrupaciones, al que le asignaremos un nombre para poder dirigirnos a él como es debido. Por lo tanto, el fragmento anterior quedará transformado de la siguiente forma:


            var consulta = from pedido in DataLists.ListaPedidos
                                         join cliente in DataLists.ListaClientes
                                              on pedido.IdCliente equals cliente.Id
                                         group pedido by new
                                         {
                                             cliente.Id,
                                             cliente.Nombre
                                         } into pedidosPorCliente
                                         select new
                                         {
                                             IdCliente = pedidosPorCliente.Key.Id,                          // Id del cliente
                                             NombreCliente = pedidosPorCliente.Key.Nombre,                  // Nombre del cliente
                                             ListaPedidos = from lineaPedido in DataLists.ListaLineasPedido // Grupo de líneas de pedido
                                                            join pedido in DataLists.ListaPedidos
                                                                on lineaPedido.IdPedido equals pedido.Id
                                                            group lineaPedido by new
                                                            {
                                                                pedido.Id,
                                                                pedido.FechaPedido
                                                            } into lineasPorPedido
                                                            select lineasPorPedido
                             };

La forma de esta estructura ya es mucho más sencilla de manejar. Hacemos lo propio con la segunda select, a la que añadiremos también el ID del pedido y su fecha, que forman parte de la clave de la agrupación.


            var consulta = from pedido in DataLists.ListaPedidos
                                         join cliente in DataLists.ListaClientes
                                              on pedido.IdCliente equals cliente.Id
                                         group pedido by new
                                         {
                                             cliente.Id,
                                             cliente.Nombre
                                         } into pedidosPorCliente
                                         select new
                                         {
                                             IdCliente = pedidosPorCliente.Key.Id,                          // Id del cliente
                                             NombreCliente = pedidosPorCliente.Key.Nombre,                  // Nombre del cliente
                                             ListaPedidos = from lineaPedido in DataLists.ListaLineasPedido // Grupo de líneas de pedido
                                                            join pedido in DataLists.ListaPedidos
                                                                on lineaPedido.IdPedido equals pedido.Id
                                                            group lineaPedido by new
                                                            {
                                                                pedido.Id,
                                                                pedido.FechaPedido
                                                            } into lineasPorPedido
                                                            select new {
                                                                IdPedido = lineasPorPedido.Key.Id,             // Id del pedido
                                                                FechaPedido = lineasPorPedido.Key.FechaPedido, // Fecha del pedido
                                                                ListaLineas = lineasPorPedido                  // Listado de objetos de la clase LineaPedido
                                                           }
                             };

Ahora mismo tendríamos la siguiente estructura:

  • Grupo
    • TKey: {int, string}
    • TElement: <tipo anónimo>
      • IdCliente (int)
      • NombreCliente (string)
      • Grupo
        • TKey: {int, fecha}
        • TElement: <tipo anónimo>
          • IdPedido (int)
          • FechaPedido (DateTime)
          • ListaLineas (IEnumerable<LineaPedido>)

Por lo tanto, para extraer la información del producto, lo único que tendremos que hacer será sustituir el valor de ListaLineas por la tercera consulta individual que declaramos más arriba, en la que realizábamos un join entre LineaPedido y Producto y devolvíamos como resultado un nuevo tipo anónimo con los valores deseados. Buscamos, por lo tanto, la siguiente estructura:

  • Grupo
    • TKey: {int, string}
    • TElement: <tipo anónimo>
      • IdCliente (int)
      • NombreCliente (string)
      • Grupo
        • TKey: {int, fecha}
        • TElement: <tipo anónimo>
          • IdPedido (int)
          • FechaPedido (DateTime)
          • ListaLineas <tipo anónimo>
            • IdLineaPedido (int)
            • Nombre (string)
            • Cantidad (int)
            • PrecioUnitario (float)
            • PrecioTotal (float)

El código final para nuestra consulta será el siguiente:


            var consulta = from pedido in DataLists.ListaPedidos
                            join cliente in DataLists.ListaClientes
                                 on pedido.IdCliente equals cliente.Id
                            group pedido by new
                            {
                                cliente.Id,
                                cliente.Nombre
                            } into pedidosPorCliente
                            select new
                            {
                                IdCliente = pedidosPorCliente.Key.Id,
                                NombreCliente = pedidosPorCliente.Key.Nombre,
                                ListaPedidos = from lineaPedido in DataLists.ListaLineasPedido
                                               join pedido in DataLists.ListaPedidos
                                                   on lineaPedido.IdPedido equals pedido.Id
                                               group lineaPedido by new
                                               {
                                                   pedido.Id,
                                                   pedido.FechaPedido
                                               } into lineasPorPedido
                                               select new
                                               {
                                                   IdPedido = lineasPorPedido.Key.Id,
                                                   FechaPedido = lineasPorPedido.Key.FechaPedido,
                                                   ListaLineas = from linea in lineasPorPedido
                                                                 join producto in DataLists.ListaProductos
                                                                   on linea.IdProducto equals producto.Id
                                                                 select new
                                                                 {
                                                                     IdLineaPedido = linea.Id,
                                                                     Nombre = producto.Descripcion,
                                                                     Cantidad = linea.Cantidad,
                                                                     PrecioUnitario = producto.Precio,
                                                                     PrecioTotal = (producto.Precio * linea.Cantidad)
                                                                 }
                                               }
                            };

Ahora podremos iterar tranquilamente para extraer la información de nuestro objeto. El siguiente código se encargará de ello:


            foreach (var cliente in consulta)
            {
                Console.WriteLine(string.Format("CLIENTE: {0}. Ha realizado {1} Pedidos",
                    cliente.NombreCliente, cliente.ListaPedidos.Count()));

                foreach (var pedido in cliente.ListaPedidos)
                {
                    Console.WriteLine(string.Format("\tPEDIDO NUMERO {0} ({1}). Lineas de pedido: {2}",
                        pedido.IdPedido, pedido.FechaPedido, pedido.ListaLineas.Count()));

                    foreach(var lineaPedido in pedido.ListaLineas)
                    {
                        Console.WriteLine(string.Format("\t\tPRODUCTO: {0}. Precio {1} x {2} uds. = {3}",
                        lineaPedido.Nombre, lineaPedido.Cantidad, lineaPedido.PrecioUnitario, lineaPedido.PrecioTotal));
                    }
                }
                Console.WriteLine("---------------------------------------------------");
            }
            Console.ReadLine();

El resultado de este código lo podemos ver a continuación:

5 comentarios

  1. Gracias por este articulo y curso en general en esta ocasión queda claro el objetivo de mostrar o realizar agrupaciones, así como también agrupaciones anidadas más sin embargo para que la data mostrada tenga un poco más de lógica sería conveniente enlazar los clientes con los pedidos ya que siguiendo este ejemplo muestra por cada cliente 12 pedidos en otras palabras realizar la relación cliente > pedidos, a manera de aporte para futuros visitantes lo conseguí adicionado lo siguiente

    where pedido.IdCliente == pedidosPorCliente.Key.Id

    por lo que la lista de pedidos quedara así

    ListaPedidos = from lineaPedido in DataLists.ListaLineasPedido
    join pedido in DataLists.ListaPedidos
    on lineaPedido.IdPedido equals pedido.Id
    where pedido.IdCliente == pedidosPorCliente.Key.Id

    Pero como repito el objetivo fue explicado sumamente muy claro, gracias por compartir Daniel

Deja un comentario