.NET

9 set, 2019

Entendendo LINQ #2: Como usar o GroupBy

Publicidade

Hey, se você caiu aqui de paraquedas, esta é uma sequência de outro post

O método GroupBy serve para agrupar elementos de uma coleção de acordo com um determinado valor (ou conjunto de valores) comum a eles e seu funcionamento é análogo à cláusula GROUP BY dos bancos de dados relacionais.

GroupBy divide uma coleção em um grupo de coleções menores de acordo com o “critério de agrupamento” informado por parâmetro. De forma geral, os agrupamentos têm como propósito realizar uma ou mais agregações em cada grupo.


A primeira coisa que é preciso entender é como funciona a principal estrutura de retorno do método.

IEnumerable<IGrouping<TKey, TSource>>

Vendo a assinatura acima nota-se que o método irá retornar uma coleção de IGrouping's. Mas o que é este IGrouping?

IGrouping é uma interface que herda de IEnumerable, portanto ela é também um enumerável, uma coleção. Isso quer dizer que o método retorna uma coleção de coleções, ou melhor, uma coleção de grupos. A definição desta interface é a seguinte:

interface IGrouping<out TKey, out TElement>: IEnumerable<TElement> 
{
    TKey Key { get; } 
}

Cada grupo vai conter todos os elementos que atendam ao mesmo critério de agrupamento e também uma propriedade Key que carregará o valor (ou valores) em comum entre estes elementos.

Por exemplo: Ao agrupar uma lista de compras pelo mês da compra utilizando o GroupBy, o retorno será uma nova coleção com todos os grupos de compras para cada mês diferente.

Para efeito de comparação, é possível pensar nesta estrutura como um dicionário onde os elementos sejam coleções (listas, arrays, entre outros).

IDictionary<TipoChave, IEnumerable<TipoElemento>>

É importante ter em mente que, diferentemente de um dicionário, os grupos não contêm uma propriedade Value

Code time

Primeiro, vamos usar uma classe Compra e criar um array de compras com alguns dados.

class Compra
{
    public string Cliente { get; set; }
    public decimal Valor { get; set; }
    public DateTime Data { get; set; }

    public override string ToString() => $"{Cliente}, com valor de {Valor:n2} em {Data:dd/MM}";
}

IReadOnlyCollection<Compra> Compras => new []
{
    new Compra { Valor =  100, Data = new DateTime(2019, 01, 19), Cliente = "Finn" },
    new Compra { Valor =  200, Data = new DateTime(2019, 02, 05), Cliente = "Jake" },
    new Compra { Valor = 1350, Data = new DateTime(2019, 02, 07), Cliente = "BMO" },
    new Compra { Valor =  900, Data = new DateTime(2019, 01, 21), Cliente = "Finn" },
    new Compra { Valor =  150, Data = new DateTime(2019, 02, 05), Cliente = "Jake" },
    new Compra { Valor = 1500, Data = new DateTime(2019, 02, 09), Cliente = "Finn" },
    new Compra { Valor =  300, Data = new DateTime(2019, 03, 10), Cliente = "Marceline" },
    new Compra { Valor = 1200, Data = new DateTime(2019, 03, 15), Cliente = "Marceline" },
    new Compra { Valor = 1900, Data = new DateTime(2019, 01, 10), Cliente = "Marceline" },
    new Compra { Valor = 2500, Data = new DateTime(2019, 03, 18), Cliente = "Finn" }
};

Como dito anteriormente, é possível agrupar as compras pelo mês da compra. Veja o código abaixo.

IEnumerable<IGrouping<int, Compra>> grupos 
    = Compras.GroupBy(compra => compra.Pagamento.Month); // Como deve ser agrupado

foreach(var grupo in grupos) // Coleção de grupos
{
    WriteLine($"Compras pagas no mês de {_dtFormat.GetMonthName(grupo.Key)}"); // Chave do agrupamento
    foreach(var elemento in grupo) // Compras do grupo => IGrouping<int, Compra>
    {
        WriteLine($"\t{elemento.ToString()}");
    }
}

Também é possível agrupar as compras pelo cliente.

IEnumerable<IGrouping<string, Compra>> grupos 
    = Compras.GroupBy(x => x.Cliente); // Como deve ser agrupado
    
foreach(var grupo in grupos) // Coleção de grupos
{
    WriteLine($"Compras de {grupo.Key}");
    foreach(var compra in grupo) // Compras do grupo => IGrouping<string, Compra>
    {
        WriteLine($"\t {compra.Valor:n2}");
    }
}

No código acima, fica claro que o retorno do GroupBy é uma coleção (IEnumerable) de IGrouping. Ou seja, cada elemento desta coleção é uma outra coleção formada pelo conjunto de todos os elementos da coleção original que atendem um mesmo critério de agrupamento, a propriedade Key é justamente o valor deste critério de agrupamento.

A tabela abaixo ilustra como ficam os dados na variável grupos para os dois códigos.

Ilustração das estruturas de agrupamento retornas pelo GroupBy

Agregações

Como dito no início, é bem comum se agrupar dados para aplicar funções de agregação nestes grupos. Com LINQ, é muito simples fazer estas agregações.

No código abaixo, é produzida uma nova coleção contendo algumas informações através das funções de agregação.

var agg = grupos.Select(gp => new 
{
    Cliente = gp.Key,
    QuantidadeCompras = gp.Count(),
    MelhorCompra = gp.Max(x => x.Valor),
    PiorCompra = gp.Min(x => x.Valor),
    TotalCompras = gp.Sum(x => x.Valor)
});

foreach(var elemento in agg) 
{
    WriteLine($"Cliente: {elemento.Cliente}");
    WriteLine($"Quantidade de compras: {elemento.QuantidadeCompras}");
    WriteLine($"Melhor Compra: {elemento.MelhorCompra}");
    WriteLine($"Pior Compra: {elemento.PiorCompra}");
    WriteLine($"Total em compras: {elemento.TotalCompras}");
}

É importante notar que gp é também uma coleção, portanto é possível filtrá-la usando Where, fazer projeções usando Select, etc.

O código abaixo, filtra por clientes que tenham mais de uma compra e, na projeção, a propriedade PiorCompra representa a pior compra (a com menor valor) acima de $ 150.

// Apenas clientes com mais de uma compra
var aggComFiltro = grupos.Where(gp => gp.Count() > 1).Select(gp => new 
{
    Cliente = gp.Key,
    PiorCompra = gp.Where(c => c.Valor > 150).Min(x => x.Valor)
    // Apenas compras acima de 150
});

foreach(var elemento in aggComFiltro) 
{
    WriteLine($"Cliente: {elemento.Cliente}");
    WriteLine($"Pior compra (acima de 150): {elemento.PiorCompra}");
}

Da mesma forma, também é possível aninhar agrupamentos, produzindo resultados mais complexos.

Pra finalizar, veja o código abaixo. Nele a lista de compras é agrupada pelo mês da compra e posteriormente, nos grupos de cada mês, é feito um segundo agrupamento pelo cliente da compra. Além disso, para cada agrupamento (tanto por mês, quanto por cliente) são adicionadas informações extras como a quantidade, o total em compras e o maior valor dentre as compras.

var comprasPorMesECliente = Compras.GroupBy(compra => compra.Data.Month).Select(gp => new 
{
    Mes = _dtFormat.GetMonthName(gp.Key),
    TotalComprasMes = gp.Sum(c => c.Valor),
    MaiorCompraMes = gp.Max(c => c.Valor),
    QtdComprasMes = gp.Count(),

    ComprasPorCliente = gp.GroupBy(c => c.Cliente).Select(g => new 
    {
        Cliente = g.Key,
        Compras = g.ToArray(),
        Total = g.Sum(c => c.Valor),
        MaiorValor = g.Max(c => c.Valor),
        Qtd = g.Count()
    })
});

foreach(var grupo in comprasPorMesECliente)
{
    WriteLine($"Mês: {grupo.Mes}");
    WriteLine($"{grupo.QtdComprasMes} compras, com um total de {grupo.TotalComprasMes}.");
    WriteLine($"A maior compra foi de {grupo.MaiorCompraMes:n2}");

    foreach(var grupoInterno in grupo.ComprasPorCliente) 
    {
        WriteLine($"\t{grupoInterno.Cliente} fez {grupoInterno.Qtd} compras, com um total de {grupoInterno.Total}");
        WriteLine($"\tA maior compra foi de {grupoInterno.MaiorValor:n2}");
        foreach(var elemento in grupoInterno.Compras)
        {
            WriteLine($"\t\t{elemento.Valor:n2} em {elemento.Data:dd/MM}");
        }                
    }            
}
Veja funcionando no repl.it

O código dos exemplos pode ser executado e editado no Repl.it