Linq: GroupBy, Soma e Contagem

133

Eu tenho uma coleção de produtos

public class Product {

   public Product() { }

   public string ProductCode {get; set;}
   public decimal Price {get; set; }
   public string Name {get; set;}
}

Agora, quero agrupar a coleção com base no código do produto e retornar um objeto que contenha o nome, o número ou produtos de cada código e o preço total de cada produto.

public class ResultLine{

   public ResultLine() { }

   public string ProductName {get; set;}
   public string Price {get; set; }
   public string Quantity {get; set;}
}

Então, eu uso um GroupBy para agrupar por ProductCode, calculo a soma e também conto o número de registros para cada código de produto.

Isto é o que eu tenho até agora:

List<Product> Lines = LoadProducts();    
List<ResultLine> result = Lines
                .GroupBy(l => l.ProductCode)
                .SelectMany(cl => cl.Select(
                    csLine => new ResultLine
                    {
                        ProductName =csLine.Name,
                        Quantity = cl.Count().ToString(),
                        Price = cl.Sum(c => c.Price).ToString(),
                    })).ToList<ResultLine>();

Por alguma razão, a soma é feita corretamente, mas a contagem é sempre 1.

Dados Sampe:

List<CartLine> Lines = new List<CartLine>();
            Lines.Add(new CartLine() { ProductCode = "p1", Price = 6.5M, Name = "Product1" });
            Lines.Add(new CartLine() { ProductCode = "p1", Price = 6.5M, Name = "Product1" });
            Lines.Add(new CartLine() { ProductCode = "p2", Price = 12M, Name = "Product2" });

Resultado com dados de amostra:

Product1: count 1   - Price:13 (2x6.5)
Product2: count 1   - Price:12 (1x12)

O produto 1 deve ter count = 2!

Tentei simular isso em um aplicativo simples de console, mas obtive o seguinte resultado:

Product1: count 2   - Price:13 (2x6.5)
Product1: count 2   - Price:13 (2x6.5)
Product2: count 1   - Price:12 (1x12)

Produto1: deve ser listado apenas uma vez ... O código para o anterior pode ser encontrado em pastebin: http://pastebin.com/cNHTBSie

ThdK
fonte

Respostas:

285

Não entendo de onde vem o primeiro "resultado com dados de amostra", mas o problema no aplicativo de console é que você está usando SelectManypara examinar cada item de cada grupo .

Eu acho que você só quer:

List<ResultLine> result = Lines
    .GroupBy(l => l.ProductCode)
    .Select(cl => new ResultLine
            {
                ProductName = cl.First().Name,
                Quantity = cl.Count().ToString(),
                Price = cl.Sum(c => c.Price).ToString(),
            }).ToList();

O uso First()aqui para obter o nome do produto pressupõe que todo produto com o mesmo código de produto tenha o mesmo nome de produto. Conforme observado nos comentários, você pode agrupar por nome de produto e código de produto, que fornecerão os mesmos resultados se o nome for sempre o mesmo para qualquer código, mas aparentemente gerar um SQL melhor no EF.

Eu também sugeriria que você alterasse as propriedades Quantitye Pricepara ser inte decimaltipos respectivamente - por que usar uma propriedade string para dados que claramente não são textuais?

Jon Skeet
fonte
Ok, meu aplicativo de console está funcionando. Obrigado por me indicar para usar First () e deixar de fora SelectMany. O ResultLine é realmente um ViewModel. O preço será formatado com sinal de moeda. É por isso que preciso que seja uma string. Mas eu poderia alterar a quantidade para int .. Vou ver agora se isso também pode ajudar no meu site. Eu aviso você.
ThdK
6
@ThdK: Não, você também deve manter Priceum decimal e depois alterar a formatação. Mantenha a representação de dados limpa e altere apenas para uma exibição de apresentação no último momento possível.
Jon Skeet
4
Por que não agrupar por ProductCode e nome? Algo assim: .GroupBy (l => new {l.ProductCode, l.Name}) e uso ProductName = c.Key.Name,
Kirill Bestemyanov
@KirillBestemyanov: Sim, essa é outra opção, certamente.
91313 Jon Skeet
Ok, parece que minha coleção era na verdade um invólucro em torno da coleção verdadeira .. Atire em mim .. Boa coisa é, eu praticado algum Linq hoje :)
ThdK
27

A consulta a seguir funciona. Ele usa cada grupo para fazer a seleção em vez de SelectMany. SelectManyfunciona em cada elemento de cada coleção. Por exemplo, na sua consulta, você tem um resultado de 2 coleções. SelectManyobtém todos os resultados, um total de 3, em vez de cada coleção. O código a seguir funciona em cada IGroupingparte da seleção para que suas operações agregadas funcionem corretamente.

var results = from line in Lines
              group line by line.ProductCode into g
              select new ResultLine {
                ProductName = g.First().Name,
                Price = g.Sum(pc => pc.Price).ToString(),
                Quantity = g.Count().ToString(),
              };
Charles Lambert
fonte
2

Às vezes, você precisa selecionar alguns campos FirstOrDefault()ou singleOrDefault()pode usar a consulta abaixo:

List<ResultLine> result = Lines
    .GroupBy(l => l.ProductCode)
    .Select(cl => new Models.ResultLine
            {
                ProductName = cl.select(x=>x.Name).FirstOrDefault(),
                Quantity = cl.Count().ToString(),
                Price = cl.Sum(c => c.Price).ToString(),
            }).ToList();
Mahdi Jalali
fonte
1
você pode explicar por que às vezes eu preciso usar FirstOrDefault() or singleOrDefault () `?
Shanteshwar Inde
@ShanteshwarInde First () e FirstOrDefault () obtêm o primeiro objeto de uma série, enquanto Single () e SingleOrDefault () esperam apenas 1 do resultado. Se Single () e SingleOrDefault () perceberem que há mais de 1 objeto em um conjunto de resultados ou como resultado do argumento fornecido, ele lançará uma exceção. No uso, você usa o primeiro quando deseja apenas, talvez uma amostra de uma série e os outros objetos não sejam importantes para você, enquanto o último se você estiver esperando apenas um objeto e fizer alguma coisa se houver mais de um resultado , como registrar o erro.
Kristianne Nerona